diff --git a/make_post_sell/models/tag.py b/make_post_sell/models/tag.py index 71e9582..971b811 100644 --- a/make_post_sell/models/tag.py +++ b/make_post_sell/models/tag.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import Column, BigInteger, Unicode, UniqueConstraint, func +from sqlalchemy import Column, BigInteger, Integer, Unicode, UniqueConstraint, func from slugify import slugify @@ -28,6 +28,11 @@ class Tag(RBase, Base): name = Column(Unicode(64), nullable=False) slug = Column(Unicode(80), nullable=False) created_timestamp = Column(BigInteger, nullable=False) + # Shop-operator-controlled display position for chip strips and lane + # sections on the home page (smaller = earlier). Defaults to 0; ties + # break on product_count desc then name asc. Reordered via the bulk + # tagger up/down buttons (no-JS) or drag-and-drop (with JS). + position = Column(Integer, nullable=False, server_default="0", default=0) shop = relationship( argument="Shop", uselist=False, lazy="joined", back_populates="tags" @@ -92,8 +97,14 @@ def get_or_create_tag(dbsession, shop, name): def tags_by_popularity(dbsession, shop, limit=None): """ - Return tags scoped to a shop, ordered by product count desc then name asc. - Limited to `limit` rows if provided. + Return tags scoped to a shop in display order: ``position`` first + (shop-operator manual ordering — smaller = earlier; defaults to 0 + for every tag so the next two clauses do the real work on a fresh + shop), then product count desc, then name asc. Limited to ``limit`` + rows if provided. + + Name is kept for back-compat: every existing caller wants the order + that surfaces on the home chip strip / lanes, which is exactly this. """ from .product_tag import ProductTag q = ( @@ -101,7 +112,11 @@ def tags_by_popularity(dbsession, shop, limit=None): .outerjoin(ProductTag, ProductTag.tag_id == Tag.id) .filter(Tag.shop_id == shop.id) .group_by(Tag.id) - .order_by(func.count(ProductTag.id).desc(), Tag.name.asc()) + .order_by( + Tag.position.asc(), + func.count(ProductTag.id).desc(), + Tag.name.asc(), + ) ) if limit: q = q.limit(limit) diff --git a/make_post_sell/scripts/alembic/versions/7f2a91c4d810_mps_24_add_tag_position.py b/make_post_sell/scripts/alembic/versions/7f2a91c4d810_mps_24_add_tag_position.py new file mode 100644 index 0000000..1f8bd46 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/7f2a91c4d810_mps_24_add_tag_position.py @@ -0,0 +1,48 @@ +"""MPS-24: add mps_tag.position for operator-controlled tag order + +Revision ID: 7f2a91c4d810 +Revises: c792642911e2 +Create Date: 2026-05-15 13:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7f2a91c4d810' +down_revision = 'c792642911e2' +branch_labels = None +depends_on = None + + +def _column_exists(table, column): + conn = op.get_bind() + result = conn.execute(sa.text(f"PRAGMA table_info({table})")) + return any(row[1] == column for row in result.fetchall()) + + +def upgrade(): + """Add mps_tag.position (NOT NULL, default 0). + + Smaller = earlier on the chip strip / lane sections. Existing + tags all start at 0; tags_by_popularity() then falls through to + product_count desc + name asc, so the on-disk order doesn't change + for shops that don't manually reorder. Server-side default keeps + NOT NULL safe for SQLite back-fill of existing rows.""" + if not _column_exists("mps_tag", "position"): + op.add_column( + "mps_tag", + sa.Column( + "position", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + + +def downgrade(): + if _column_exists("mps_tag", "position"): + with op.batch_alter_table("mps_tag") as batch: + batch.drop_column("position") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 1eac77a..33123d7 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -1523,13 +1523,32 @@ ul.tag-list { li.tag-list-item { display: grid; - grid-template-columns: auto 1fr auto auto; + /* handle | chip | count | view | up | down | delete */ + grid-template-columns: auto auto 1fr auto auto auto auto; align-items: center; gap: var(--space-3, 12px); padding: var(--space-2, 8px); border: 1px solid var(--color-border, #e5e7eb); border-radius: var(--radius-sm, 4px); } +li.tag-list-item.tag-list-drop-target { + /* Active drop position during drag-and-drop reordering. */ + background: color-mix(in srgb, var(--color-green, #a3c765) 14%, var(--surface-base, #fff) 86%); +} + +span.tag-list-handle { + cursor: grab; + color: var(--text-muted, #777); + font-size: var(--text-md, 1.1rem); + user-select: none; + line-height: 1; +} + +form.tag-list-reorder { margin: 0; } +form.tag-list-reorder button[disabled] { + opacity: 0.4; + cursor: not-allowed; +} span.tag-list-count { color: var(--color-text-muted, #6b7280); diff --git a/make_post_sell/static/js/tag_bulk.js b/make_post_sell/static/js/tag_bulk.js index 74976ff..38efecf 100644 --- a/make_post_sell/static/js/tag_bulk.js +++ b/make_post_sell/static/js/tag_bulk.js @@ -82,11 +82,45 @@ case "delete": return onDelete(form, data); case "attach": return onToggle(form, data, true); case "detach": return onToggle(form, data, false); + case "reorder": return onReorder(form, data); case "apply_suggestion": return onApplySuggestion(form, data); case "dismiss_suggestion": return onDismissSuggestion(form, data); } } + /* Up/down row swap — keeps the DOM in sync with the position swap the + * server just performed, so the user sees the new order without a full + * page reload. Also re-disables the ↑ on the first row and the + * ↓ on the last row after the swap. */ + function onReorder(form, data) { + if (!data || !data.moved) return; + const list = document.querySelector("[data-tag-list]"); + const row = form.closest("[data-tag-row]"); + if (!list || !row) return; + const direction = data.direction; + if (direction === "up") { + const prev = row.previousElementSibling; + if (prev) list.insertBefore(row, prev); + } else if (direction === "down") { + const next = row.nextElementSibling; + if (next) list.insertBefore(next, row); + } + syncReorderButtonStates(list); + } + + function syncReorderButtonStates(list) { + const rows = list.querySelectorAll("[data-tag-row]"); + rows.forEach(function (row, i) { + row.querySelectorAll("form.tag-list-reorder").forEach(function (f) { + const dir = f.querySelector('input[name=direction]'); + const btn = f.querySelector("button[type=submit]"); + if (!dir || !btn) return; + const isUp = dir.value === "up"; + btn.disabled = isUp ? i === 0 : i === rows.length - 1; + }); + }); + } + /* ----- handlers --------------------------------------------------- */ function onCreate(form, data) { diff --git a/make_post_sell/templates/shop_tags.j2 b/make_post_sell/templates/shop_tags.j2 index a82a1f9..31634c4 100644 --- a/make_post_sell/templates/shop_tags.j2 +++ b/make_post_sell/templates/shop_tags.j2 @@ -98,9 +98,11 @@ {% if not tags %}
No tags yet. Create one above, or open a product and add a tag inline.
{% endif %} +Order controls the chip strip + lane sequence on the shop home page. Use the ↑ / ↓ buttons (or drag rows when JS is enabled) to reorder.