cellarbrain 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cellarbrain-0.2.0/src/cellarbrain.egg-info → cellarbrain-0.2.2}/PKG-INFO +11 -3
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/README.md +10 -2
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/pyproject.toml +7 -1
- cellarbrain-0.2.2/src/cellarbrain/__init__.py +7 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/cli.py +26 -4
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/app.py +51 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/static/dashboard.js +101 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/static/workbench.js +25 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/base.html +162 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/bottles.html +68 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/cellar.html +126 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/drinking.html +53 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/error.html +12 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/errors.html +67 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/index.html +90 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/latency.html +68 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/live.html +102 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/pairing.html +84 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/bottle_rows.html +13 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/dossier_section.html +8 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/error_detail.html +17 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/error_rows.html +18 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/event_stream.html +22 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/pairing_results.html +42 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/session_detail.html +25 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/sql_results.html +34 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/stats_content.html +46 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/tool_rows.html +12 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/turn_events.html +18 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/wine_rows.html +15 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/partials/workbench_response.html +30 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/sessions.html +40 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/sql.html +58 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/stats.html +61 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/tools.html +25 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/tracked.html +44 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/tracked_detail.html +120 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/wine_detail.html +141 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/workbench_batch.html +98 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/workbench_list.html +40 -0
- cellarbrain-0.2.2/src/cellarbrain/dashboard/templates/workbench_tool.html +72 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/workbench.py +1 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/doctor.py +1 -1
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/mcp_server.py +139 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/observability.py +42 -1
- cellarbrain-0.2.2/src/cellarbrain/pairing.py +884 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/transform.py +1 -1
- {cellarbrain-0.2.0 → cellarbrain-0.2.2/src/cellarbrain.egg-info}/PKG-INFO +11 -3
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/SOURCES.txt +38 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_cli.py +25 -0
- cellarbrain-0.2.2/tests/test_dashboard_pairing.py +198 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_doctor.py +2 -2
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_mcp_server.py +69 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_observability.py +34 -0
- cellarbrain-0.2.2/tests/test_pairing.py +687 -0
- cellarbrain-0.2.0/src/cellarbrain/__init__.py +0 -1
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/LICENSE +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/setup.cfg +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/__main__.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/_query_base.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/backup.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/companion_markdown.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/computed.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/__init__.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/cellar_queries.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/dossier_render.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dashboard/queries.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/dossier_ops.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/__init__.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/credentials.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/etl_runner.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/grouping.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/imap.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/email_poll/placement.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/flat.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/incremental.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/log.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/markdown.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/parsers.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/price.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/query.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/search.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/settings.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/slugify.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/__init__.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/catalogue.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/engine.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/index.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/model.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/schemas.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/text_builder.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/sommelier/training.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/validate.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/vinocell_parsers.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/vinocell_reader.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain/writer.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/dependency_links.txt +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/entry_points.txt +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/requires.txt +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/src/cellarbrain.egg-info/top_level.txt +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_backup.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_catalogue.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_companion_markdown.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_computed.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_app.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_cellar.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_dossier.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_queries.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dashboard_workbench.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dataset_factory.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_dossier_ops.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_email_poll.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_flat.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_incremental.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_integration.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_log.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_markdown.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_parsers.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_price.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_query.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_reader.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_search.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_settings.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_data.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_mcp.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_quality.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_sommelier_training.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_transform.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_validate.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_vinocell_parsers.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_vinocell_reader.py +0 -0
- {cellarbrain-0.2.0 → cellarbrain-0.2.2}/tests/test_writer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cellarbrain
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports
|
|
5
5
|
Author-email: Urban Busslinger <urbanb@me.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -324,9 +324,17 @@ src/cellarbrain/
|
|
|
324
324
|
|
|
325
325
|
## Development
|
|
326
326
|
|
|
327
|
+
> **Note:** Unit tests and smoke tests require a source checkout of the
|
|
328
|
+
> repository. They are not included in the PyPI package.
|
|
329
|
+
|
|
327
330
|
```bash
|
|
328
|
-
#
|
|
329
|
-
|
|
331
|
+
# Clone and set up for development
|
|
332
|
+
git clone https://github.com/urban-buss/cellarbrain.git
|
|
333
|
+
cd cellarbrain
|
|
334
|
+
python -m venv .venv
|
|
335
|
+
.venv\Scripts\activate # Windows
|
|
336
|
+
source .venv/bin/activate # macOS / Linux
|
|
337
|
+
pip install -e ".[dev,research]"
|
|
330
338
|
|
|
331
339
|
# Run tests
|
|
332
340
|
pytest tests/ -v
|
|
@@ -281,9 +281,17 @@ src/cellarbrain/
|
|
|
281
281
|
|
|
282
282
|
## Development
|
|
283
283
|
|
|
284
|
+
> **Note:** Unit tests and smoke tests require a source checkout of the
|
|
285
|
+
> repository. They are not included in the PyPI package.
|
|
286
|
+
|
|
284
287
|
```bash
|
|
285
|
-
#
|
|
286
|
-
|
|
288
|
+
# Clone and set up for development
|
|
289
|
+
git clone https://github.com/urban-buss/cellarbrain.git
|
|
290
|
+
cd cellarbrain
|
|
291
|
+
python -m venv .venv
|
|
292
|
+
.venv\Scripts\activate # Windows
|
|
293
|
+
source .venv/bin/activate # macOS / Linux
|
|
294
|
+
pip install -e ".[dev,research]"
|
|
287
295
|
|
|
288
296
|
# Run tests
|
|
289
297
|
pytest tests/ -v
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cellarbrain"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "AI sommelier for your wine cellar — ETL pipeline, DuckDB query layer, Markdown dossiers, and MCP server for wine cellar CSV exports"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
license = "MIT"
|
|
@@ -65,6 +65,12 @@ cellarbrain = "cellarbrain.cli:main"
|
|
|
65
65
|
[tool.setuptools.packages.find]
|
|
66
66
|
where = ["src"]
|
|
67
67
|
|
|
68
|
+
[tool.setuptools.package-data]
|
|
69
|
+
"cellarbrain.dashboard" = [
|
|
70
|
+
"static/**/*",
|
|
71
|
+
"templates/**/*",
|
|
72
|
+
]
|
|
73
|
+
|
|
68
74
|
[tool.pytest.ini_options]
|
|
69
75
|
testpaths = ["tests"]
|
|
70
76
|
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import sys
|
|
10
10
|
import warnings
|
|
11
11
|
from datetime import UTC, datetime
|
|
12
|
+
from importlib.metadata import version as _pkg_version
|
|
12
13
|
|
|
13
14
|
from . import companion_markdown, incremental, markdown, transform, vinocell_reader, writer
|
|
14
15
|
from . import validate as val
|
|
@@ -472,6 +473,12 @@ def _subcommand_main(argv: list[str]) -> None:
|
|
|
472
473
|
prog="cellarbrain",
|
|
473
474
|
description="Cellarbrain wine cellar toolkit — ETL, query, and agent interface.",
|
|
474
475
|
)
|
|
476
|
+
parser.add_argument(
|
|
477
|
+
"-V",
|
|
478
|
+
"--version",
|
|
479
|
+
action="version",
|
|
480
|
+
version=f"%(prog)s {_pkg_version('cellarbrain')}",
|
|
481
|
+
)
|
|
475
482
|
parser.add_argument(
|
|
476
483
|
"-c",
|
|
477
484
|
"--config",
|
|
@@ -645,6 +652,7 @@ def _subcommand_main(argv: list[str]) -> None:
|
|
|
645
652
|
# --- doctor ---
|
|
646
653
|
doc_parser = sub.add_parser("doctor", help="Run diagnostic health checks on the data directory")
|
|
647
654
|
doc_parser.add_argument("--json", action="store_true", help="Output results as JSON (for scripting)")
|
|
655
|
+
doc_parser.add_argument("--strict", action="store_true", help="Treat warnings as errors (exit 1)")
|
|
648
656
|
doc_parser.add_argument(
|
|
649
657
|
"--check",
|
|
650
658
|
type=str,
|
|
@@ -800,6 +808,7 @@ def _cmd_mcp(args: argparse.Namespace, settings: Settings) -> None:
|
|
|
800
808
|
from .mcp_server import mcp, warm_sommelier
|
|
801
809
|
|
|
802
810
|
warm_sommelier()
|
|
811
|
+
mcp.settings.port = args.port
|
|
803
812
|
mcp.run(transport=args.transport)
|
|
804
813
|
|
|
805
814
|
|
|
@@ -1212,9 +1221,15 @@ def _cmd_dashboard(args: argparse.Namespace, settings: Settings) -> None:
|
|
|
1212
1221
|
db_path = str(pathlib.Path(settings.paths.data_dir) / "logs" / "cellarbrain-logs.duckdb")
|
|
1213
1222
|
|
|
1214
1223
|
if not pathlib.Path(db_path).exists():
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1224
|
+
# Create an empty log store so the dashboard can start without prior MCP usage
|
|
1225
|
+
pathlib.Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
1226
|
+
import duckdb
|
|
1227
|
+
|
|
1228
|
+
_con = duckdb.connect(db_path)
|
|
1229
|
+
from .observability import _CREATE_TABLE_SQL
|
|
1230
|
+
|
|
1231
|
+
_con.execute(_CREATE_TABLE_SQL)
|
|
1232
|
+
_con.close()
|
|
1218
1233
|
|
|
1219
1234
|
data_dir = settings.paths.data_dir
|
|
1220
1235
|
app = create_app(
|
|
@@ -1251,7 +1266,14 @@ def _cmd_doctor(args: argparse.Namespace, settings: Settings) -> None:
|
|
|
1251
1266
|
print(json.dumps(data, indent=2))
|
|
1252
1267
|
else:
|
|
1253
1268
|
print(report.summary())
|
|
1254
|
-
|
|
1269
|
+
|
|
1270
|
+
from .doctor import Severity
|
|
1271
|
+
|
|
1272
|
+
if args.strict:
|
|
1273
|
+
is_ok = report.worst_severity in (Severity.OK, Severity.INFO)
|
|
1274
|
+
else:
|
|
1275
|
+
is_ok = report.ok
|
|
1276
|
+
sys.exit(0 if is_ok else 1)
|
|
1255
1277
|
|
|
1256
1278
|
|
|
1257
1279
|
def _cmd_backup(args: argparse.Namespace, settings: Settings) -> None:
|
|
@@ -471,6 +471,56 @@ async def drinking(request: Request) -> HTMLResponse:
|
|
|
471
471
|
)
|
|
472
472
|
|
|
473
473
|
|
|
474
|
+
# ---- Food pairing ---------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def pairing_page(request: Request) -> HTMLResponse:
|
|
478
|
+
"""Food pairing interactive page — classify dish, retrieve candidates."""
|
|
479
|
+
con = request.app.state.cellar_con
|
|
480
|
+
if con is None:
|
|
481
|
+
return _TEMPLATES.TemplateResponse(
|
|
482
|
+
request,
|
|
483
|
+
"error.html",
|
|
484
|
+
context={"message": "Cellar data not available."},
|
|
485
|
+
status_code=503,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
results = None
|
|
489
|
+
params: dict[str, str | None] = {}
|
|
490
|
+
|
|
491
|
+
if request.method == "POST":
|
|
492
|
+
form = await request.form()
|
|
493
|
+
params = {
|
|
494
|
+
"dish_description": form.get("dish_description", ""),
|
|
495
|
+
"category": form.get("category") or None,
|
|
496
|
+
"weight": form.get("weight") or None,
|
|
497
|
+
"protein": form.get("protein") or None,
|
|
498
|
+
"cuisine": form.get("cuisine") or None,
|
|
499
|
+
"grapes": form.get("grapes") or None,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
from cellarbrain import pairing
|
|
503
|
+
|
|
504
|
+
grape_list = [g.strip() for g in params["grapes"].split(",")] if params["grapes"] else None
|
|
505
|
+
results = pairing.retrieve_candidates(
|
|
506
|
+
con,
|
|
507
|
+
dish_description=params["dish_description"],
|
|
508
|
+
category=params["category"],
|
|
509
|
+
weight=params["weight"],
|
|
510
|
+
protein=params["protein"],
|
|
511
|
+
cuisine=params["cuisine"],
|
|
512
|
+
grapes=grape_list,
|
|
513
|
+
limit=15,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
template = "partials/pairing_results.html" if _wants_partial(request) else "pairing.html"
|
|
517
|
+
return _TEMPLATES.TemplateResponse(
|
|
518
|
+
request,
|
|
519
|
+
template,
|
|
520
|
+
context={"results": results, "params": params},
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
474
524
|
# ---- SQL playground -------------------------------------------------------
|
|
475
525
|
|
|
476
526
|
_QUICK_QUERIES = [
|
|
@@ -979,6 +1029,7 @@ def build_app(
|
|
|
979
1029
|
Route("/cellar/{wine_id:int}", wine_detail),
|
|
980
1030
|
Route("/bottles", cellar_bottles),
|
|
981
1031
|
Route("/drinking", drinking),
|
|
1032
|
+
Route("/pairing", pairing_page, methods=["GET", "POST"]),
|
|
982
1033
|
Route("/sql", sql_playground, methods=["GET", "POST"]),
|
|
983
1034
|
Route("/stats", stats),
|
|
984
1035
|
Route("/tracked", tracked),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard.js — Chart.js render helpers for the observability dashboard.
|
|
3
|
+
* Loaded in base.html, called from inline <script> blocks in templates.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function renderBarChart(canvasId, apiUrl, label) {
|
|
7
|
+
fetch(apiUrl)
|
|
8
|
+
.then(r => r.json())
|
|
9
|
+
.then(d => {
|
|
10
|
+
new Chart(document.getElementById(canvasId), {
|
|
11
|
+
type: 'bar',
|
|
12
|
+
data: {
|
|
13
|
+
labels: d.labels,
|
|
14
|
+
datasets: [{
|
|
15
|
+
label: label || 'Calls',
|
|
16
|
+
data: d.data,
|
|
17
|
+
backgroundColor: 'rgba(99, 102, 241, 0.5)',
|
|
18
|
+
borderColor: 'rgb(99, 102, 241)',
|
|
19
|
+
borderWidth: 1,
|
|
20
|
+
}]
|
|
21
|
+
},
|
|
22
|
+
options: { responsive: true, scales: { y: { beginAtZero: true } } }
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderLatencyChart(canvasId, apiUrl) {
|
|
28
|
+
fetch(apiUrl)
|
|
29
|
+
.then(r => r.json())
|
|
30
|
+
.then(d => {
|
|
31
|
+
new Chart(document.getElementById(canvasId), {
|
|
32
|
+
type: 'line',
|
|
33
|
+
data: {
|
|
34
|
+
labels: d.labels,
|
|
35
|
+
datasets: [
|
|
36
|
+
{ label: 'P50', data: d.p50, borderColor: '#22c55e', fill: false },
|
|
37
|
+
{ label: 'P95', data: d.p95, borderColor: '#ef4444', fill: false },
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
options: { responsive: true, scales: { y: { beginAtZero: true } } }
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderHistogram(canvasId, apiUrl) {
|
|
46
|
+
fetch(apiUrl)
|
|
47
|
+
.then(r => r.json())
|
|
48
|
+
.then(d => {
|
|
49
|
+
new Chart(document.getElementById(canvasId), {
|
|
50
|
+
type: 'bar',
|
|
51
|
+
data: {
|
|
52
|
+
labels: d.buckets,
|
|
53
|
+
datasets: [{
|
|
54
|
+
label: 'Count',
|
|
55
|
+
data: d.counts,
|
|
56
|
+
backgroundColor: 'rgba(234, 179, 8, 0.5)',
|
|
57
|
+
}]
|
|
58
|
+
},
|
|
59
|
+
options: { responsive: true, scales: { y: { beginAtZero: true } } }
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function refreshConnection() {
|
|
65
|
+
fetch('/api/refresh', { method: 'POST' })
|
|
66
|
+
.then(r => r.json())
|
|
67
|
+
.then(() => window.location.reload());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderDoughnutChart(canvasId, labels, data) {
|
|
71
|
+
const colors = [
|
|
72
|
+
'#6366f1', '#ec4899', '#f59e0b', '#10b981',
|
|
73
|
+
'#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#94a3b8'
|
|
74
|
+
];
|
|
75
|
+
new Chart(document.getElementById(canvasId), {
|
|
76
|
+
type: 'doughnut',
|
|
77
|
+
data: {
|
|
78
|
+
labels: labels,
|
|
79
|
+
datasets: [{
|
|
80
|
+
data: data,
|
|
81
|
+
backgroundColor: colors.slice(0, data.length),
|
|
82
|
+
}]
|
|
83
|
+
},
|
|
84
|
+
options: { responsive: true, plugins: { legend: { position: 'right' } } }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderLineChart(canvasId, datasets) {
|
|
89
|
+
new Chart(document.getElementById(canvasId), {
|
|
90
|
+
type: 'line',
|
|
91
|
+
data: { datasets: datasets },
|
|
92
|
+
options: {
|
|
93
|
+
responsive: true,
|
|
94
|
+
scales: {
|
|
95
|
+
x: { type: 'category', title: { display: true, text: 'Month' } },
|
|
96
|
+
y: { beginAtZero: false, title: { display: true, text: 'Price (CHF)' } },
|
|
97
|
+
},
|
|
98
|
+
plugins: { legend: { position: 'top' } },
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workbench.js — form enhancement and response rendering.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/* Copy response text to clipboard */
|
|
6
|
+
function copyResponse(elementId) {
|
|
7
|
+
var el = document.getElementById(elementId);
|
|
8
|
+
if (el) {
|
|
9
|
+
navigator.clipboard.writeText(el.textContent);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* Toggle between raw and rendered view */
|
|
14
|
+
function toggleView(rawId, renderedId) {
|
|
15
|
+
var raw = document.getElementById(rawId);
|
|
16
|
+
var rendered = document.getElementById(renderedId);
|
|
17
|
+
if (raw) raw.classList.toggle('hidden');
|
|
18
|
+
if (rendered) rendered.classList.toggle('hidden');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Quick fill a form field */
|
|
22
|
+
function fillField(fieldName, value) {
|
|
23
|
+
var input = document.querySelector('[name="' + fieldName + '"]');
|
|
24
|
+
if (input) input.value = value;
|
|
25
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{% block title %}Cellarbrain Explorer{% endblock %}</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
9
|
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
10
|
+
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--pico-font-size: 14px;
|
|
14
|
+
}
|
|
15
|
+
nav .nav-group { display: inline-block; margin-right: 1.5rem; }
|
|
16
|
+
nav .nav-group-label {
|
|
17
|
+
font-size: 0.75rem; text-transform: uppercase;
|
|
18
|
+
color: var(--pico-muted-color); display: block; margin-bottom: 0.15rem;
|
|
19
|
+
}
|
|
20
|
+
nav a { margin-right: 0.5rem; font-size: 0.85rem; }
|
|
21
|
+
nav a.active { font-weight: bold; text-decoration: underline; }
|
|
22
|
+
nav a.disabled { color: var(--pico-muted-color); pointer-events: none; opacity: 0.5; }
|
|
23
|
+
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
|
24
|
+
.kpi-card { padding: 1rem; border-radius: 8px; background: var(--pico-card-background-color); border: 1px solid var(--pico-muted-border-color); text-align: center; }
|
|
25
|
+
.kpi-card .value { font-size: 1.8rem; font-weight: bold; }
|
|
26
|
+
.kpi-card .label { font-size: 0.8rem; color: var(--pico-muted-color); }
|
|
27
|
+
.period-selector { display: inline-flex; gap: 0.25rem; }
|
|
28
|
+
.period-selector a { padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.8rem; text-decoration: none; }
|
|
29
|
+
.period-selector a.active { background: var(--pico-primary); color: white; }
|
|
30
|
+
.chart-container { position: relative; height: 250px; margin-bottom: 1.5rem; }
|
|
31
|
+
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
|
32
|
+
table { font-size: 0.85rem; }
|
|
33
|
+
.badge { padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
|
|
34
|
+
.badge-ok { background: #dcfce7; color: #166534; }
|
|
35
|
+
.badge-warn { background: #fef9c3; color: #854d0e; }
|
|
36
|
+
.badge-error { background: #fecaca; color: #991b1b; }
|
|
37
|
+
.mono { font-family: monospace; font-size: 0.8rem; }
|
|
38
|
+
.truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
39
|
+
.hidden { display: none; }
|
|
40
|
+
footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--pico-muted-border-color); font-size: 0.75rem; color: var(--pico-muted-color); }
|
|
41
|
+
/* Live tail event cards */
|
|
42
|
+
.event-card { padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--pico-muted-border-color); font-size: 0.8rem; font-family: monospace; }
|
|
43
|
+
.event-card.error { background: #fef2f2; }
|
|
44
|
+
.event-card .ts { color: var(--pico-muted-color); }
|
|
45
|
+
.event-card .type { color: var(--pico-primary); }
|
|
46
|
+
.event-card .name { font-weight: 600; }
|
|
47
|
+
.event-card .status { margin-left: 0.3rem; }
|
|
48
|
+
.event-card .ms { color: var(--pico-muted-color); margin-left: 0.3rem; }
|
|
49
|
+
.turn-boundary { padding: 0.2rem 0.5rem; font-size: 0.7rem; color: var(--pico-muted-color); text-align: center; border-bottom: 1px solid var(--pico-muted-border-color); }
|
|
50
|
+
.live-toolbar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
|
|
51
|
+
.live-toolbar select { width: auto; padding: 0.2rem 0.4rem; font-size: 0.75rem; margin: 0; }
|
|
52
|
+
.live-toolbar button { font-size: 0.75rem; padding: 0.2rem 0.6rem; }
|
|
53
|
+
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
|
|
54
|
+
.status-dot.connected { background: #22c55e; }
|
|
55
|
+
.status-dot.disconnected { background: #ef4444; }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<main class="container-fluid">
|
|
60
|
+
<nav>
|
|
61
|
+
<div class="nav-group">
|
|
62
|
+
<span class="nav-group-label">Cellar</span>
|
|
63
|
+
<a href="/cellar" class="{% if active_nav == 'cellar' %}active{% endif %}">Wines</a>
|
|
64
|
+
<a href="/bottles" class="{% if active_nav == 'bottles' %}active{% endif %}">Bottles</a>
|
|
65
|
+
<a href="/tracked" class="{% if active_nav == 'tracked' %}active{% endif %}">Tracked</a>
|
|
66
|
+
<a href="/stats" class="{% if active_nav == 'stats' %}active{% endif %}">Stats</a>
|
|
67
|
+
<a href="/drinking" class="{% if active_nav == 'drinking' %}active{% endif %}">Drinking</a>
|
|
68
|
+
<a href="/pairing" class="{% if active_nav == 'pairing' %}active{% endif %}">Pairing</a>
|
|
69
|
+
<a href="/sql" class="{% if active_nav == 'sql' %}active{% endif %}">SQL</a>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="nav-group">
|
|
72
|
+
<span class="nav-group-label">Server</span>
|
|
73
|
+
<a href="/" class="{% if active_nav == 'overview' %}active{% endif %}">Overview</a>
|
|
74
|
+
<a href="/tools" class="{% if active_nav == 'tools' %}active{% endif %}">Tool Usage</a>
|
|
75
|
+
<a href="/errors" class="{% if active_nav == 'errors' %}active{% endif %}">Errors</a>
|
|
76
|
+
<a href="/sessions" class="{% if active_nav == 'sessions' %}active{% endif %}">Sessions</a>
|
|
77
|
+
<a href="/latency" class="{% if active_nav == 'latency' %}active{% endif %}">Latency</a>
|
|
78
|
+
<a href="/live" class="{% if active_nav == 'live' %}active{% endif %}">Live</a>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="nav-group">
|
|
81
|
+
<span class="nav-group-label">Workbench</span>
|
|
82
|
+
<a href="/workbench" class="{% if active_nav == 'workbench' %}active{% endif %}">Tools</a>
|
|
83
|
+
<a href="/workbench/batch" class="{% if active_nav == 'workbench_batch' %}active{% endif %}">Batch</a>
|
|
84
|
+
</div>
|
|
85
|
+
</nav>
|
|
86
|
+
|
|
87
|
+
<div class="toolbar">
|
|
88
|
+
<h4 style="margin:0">{% block page_title %}{% endblock %}</h4>
|
|
89
|
+
<div style="display:flex; align-items:center; gap:1rem;">
|
|
90
|
+
{% if period is defined %}
|
|
91
|
+
<div class="period-selector">
|
|
92
|
+
{% for p in ["1h", "24h", "7d", "30d"] %}
|
|
93
|
+
<a href="?period={{ p }}"
|
|
94
|
+
class="{% if period == p %}active{% endif %}"
|
|
95
|
+
hx-get="?period={{ p }}" hx-push-url="true" hx-target="body">{{ p }}</a>
|
|
96
|
+
{% endfor %}
|
|
97
|
+
</div>
|
|
98
|
+
{% endif %}
|
|
99
|
+
{% if include_workbench is defined %}
|
|
100
|
+
<label style="font-size:0.7rem; margin:0; display:flex; align-items:center; gap:0.25rem; cursor:pointer;">
|
|
101
|
+
<input type="checkbox" style="width:auto; margin:0;"
|
|
102
|
+
{% if include_workbench %}checked{% endif %}
|
|
103
|
+
onchange="var url=new URL(window.location); if(this.checked){url.searchParams.set('include_workbench','1')}else{url.searchParams.delete('include_workbench')}; window.location=url;">
|
|
104
|
+
Workbench
|
|
105
|
+
</label>
|
|
106
|
+
{% endif %}
|
|
107
|
+
<button class="outline secondary" style="font-size:0.75rem; padding:0.2rem 0.5rem;" onclick="refreshConnection()">Refresh</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{% block content %}{% endblock %}
|
|
112
|
+
|
|
113
|
+
<footer>
|
|
114
|
+
Cellarbrain Explorer · Phase 5 · Live Tail
|
|
115
|
+
{% if period is defined %}
|
|
116
|
+
<span style="margin-left:1.5rem;">
|
|
117
|
+
<label style="font-size:0.7rem; margin:0; display:inline-flex; align-items:center; gap:0.25rem; cursor:pointer;">
|
|
118
|
+
<input type="checkbox" id="auto-refresh-toggle" style="width:auto; margin:0;">
|
|
119
|
+
Auto-refresh (30 s)
|
|
120
|
+
</label>
|
|
121
|
+
</span>
|
|
122
|
+
{% endif %}
|
|
123
|
+
</footer>
|
|
124
|
+
</main>
|
|
125
|
+
<script src="/static/dashboard.js"></script>
|
|
126
|
+
<script>
|
|
127
|
+
/* Toggle active class for inline tab / selector groups on HTMX click */
|
|
128
|
+
document.addEventListener('click', function(e) {
|
|
129
|
+
var link = e.target.closest('.period-selector a, [data-toggle-group] a, [data-toggle-group] [role="button"]');
|
|
130
|
+
if (!link) return;
|
|
131
|
+
var group = link.closest('.period-selector, [data-toggle-group]');
|
|
132
|
+
if (!group) return;
|
|
133
|
+
/* period-selector: toggle .active */
|
|
134
|
+
if (group.classList.contains('period-selector')) {
|
|
135
|
+
group.querySelectorAll('a').forEach(function(a) { a.classList.remove('active'); });
|
|
136
|
+
link.classList.add('active');
|
|
137
|
+
} else {
|
|
138
|
+
/* button-style toggles (contrast / outline secondary) */
|
|
139
|
+
group.querySelectorAll('a[role="button"], button').forEach(function(a) {
|
|
140
|
+
a.className = a.className.replace('contrast', 'outline secondary');
|
|
141
|
+
});
|
|
142
|
+
link.className = link.className.replace('outline secondary', 'contrast');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
</script>
|
|
146
|
+
<script>
|
|
147
|
+
(function(){
|
|
148
|
+
var toggle = document.getElementById('auto-refresh-toggle');
|
|
149
|
+
if (!toggle) return;
|
|
150
|
+
var timer = null;
|
|
151
|
+
toggle.addEventListener('change', function(){
|
|
152
|
+
if (this.checked) {
|
|
153
|
+
timer = setInterval(function(){ window.location.reload(); }, 30000);
|
|
154
|
+
} else {
|
|
155
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
})();
|
|
159
|
+
</script>
|
|
160
|
+
{% block scripts %}{% endblock %}
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% set active_nav = "bottles" %}
|
|
4
|
+
{% block title %}Bottle Inventory — Cellarbrain Explorer{% endblock %}
|
|
5
|
+
{% block page_title %}Bottle Inventory{% endblock %}
|
|
6
|
+
|
|
7
|
+
{% block content %}
|
|
8
|
+
<div style="display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap; align-items:center;">
|
|
9
|
+
<div class="period-selector">
|
|
10
|
+
{% for v, label in [("stored", "Stored"), ("on_order", "On Order"), ("consumed", "Consumed"), ("all", "All")] %}
|
|
11
|
+
<a href="/bottles?view={{ v }}&cellar={{ cellar_filter }}&category={{ category }}"
|
|
12
|
+
hx-get="/bottles?view={{ v }}&cellar={{ cellar_filter }}&category={{ category }}"
|
|
13
|
+
hx-target="#bottle-tbody" hx-swap="innerHTML"
|
|
14
|
+
class="{% if view == v %}active{% endif %}">{{ label }}</a>
|
|
15
|
+
{% endfor %}
|
|
16
|
+
</div>
|
|
17
|
+
<div style="margin-left:1rem;">
|
|
18
|
+
<select name="cellar" style="font-size:0.85rem; padding:0.2rem 0.5rem;"
|
|
19
|
+
hx-get="/bottles" hx-target="#bottle-tbody" hx-swap="innerHTML"
|
|
20
|
+
hx-include="[name='view_val'],[name='category']">
|
|
21
|
+
<option value="">All cellars</option>
|
|
22
|
+
{% for c in cellar_names %}
|
|
23
|
+
<option value="{{ c }}" {% if cellar_filter == c %}selected{% endif %}>{{ c }}</option>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
</select>
|
|
26
|
+
<input type="hidden" name="view_val" value="{{ view }}">
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<figure>
|
|
31
|
+
<table role="grid">
|
|
32
|
+
<thead>
|
|
33
|
+
<tr>
|
|
34
|
+
{% set base_params = "view=" ~ view ~ "&cellar=" ~ cellar_filter ~ "&category=" ~ category %}
|
|
35
|
+
{% for col_id, col_label in [("wine_name", "Wine"), ("vintage", "Vintage"), ("cellar_name", "Cellar"), ("shelf", "Shelf"), ("price", "Price"), ("status", "Status")] %}
|
|
36
|
+
<th>
|
|
37
|
+
<a href="/bottles?{{ base_params }}&sort={{ col_id }}&dir={% if sort == col_id and not desc %}desc{% else %}asc{% endif %}"
|
|
38
|
+
hx-get="/bottles?{{ base_params }}&sort={{ col_id }}&dir={% if sort == col_id and not desc %}desc{% else %}asc{% endif %}"
|
|
39
|
+
hx-target="#bottle-tbody" hx-swap="innerHTML">
|
|
40
|
+
{{ col_label }}{% if sort == col_id %}{{ " ▼" if desc else " ▲" }}{% endif %}
|
|
41
|
+
</a>
|
|
42
|
+
</th>
|
|
43
|
+
{% endfor %}
|
|
44
|
+
</tr>
|
|
45
|
+
</thead>
|
|
46
|
+
<tbody id="bottle-tbody">
|
|
47
|
+
{% include "partials/bottle_rows.html" %}
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
</figure>
|
|
51
|
+
|
|
52
|
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem;">
|
|
53
|
+
<span style="font-size:0.85rem; color:var(--pico-muted-color);">
|
|
54
|
+
{{ total }} bottle{{ "s" if total != 1 }}
|
|
55
|
+
</span>
|
|
56
|
+
<div style="display:flex; gap:0.5rem; align-items:center;">
|
|
57
|
+
{% if page > 1 %}
|
|
58
|
+
<a href="/bottles?page={{ page - 1 }}&{{ base_params }}&sort={{ sort }}&dir={% if desc %}desc{% else %}asc{% endif %}"
|
|
59
|
+
role="button" class="outline secondary" style="font-size:0.8rem; padding:0.2rem 0.6rem;">← Prev</a>
|
|
60
|
+
{% endif %}
|
|
61
|
+
<span style="font-size:0.85rem;">Page {{ page }} of {{ total_pages }}</span>
|
|
62
|
+
{% if page < total_pages %}
|
|
63
|
+
<a href="/bottles?page={{ page + 1 }}&{{ base_params }}&sort={{ sort }}&dir={% if desc %}desc{% else %}asc{% endif %}"
|
|
64
|
+
role="button" class="outline secondary" style="font-size:0.8rem; padding:0.2rem 0.6rem;">Next →</a>
|
|
65
|
+
{% endif %}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
{% endblock %}
|