diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 6d18e1f..190164b 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -363,6 +363,19 @@ Tests (`test_functional.py::TestProductTagsSpa`): Deferred (occasional click, not the hot path): AJAX-ifying the "Suggest categories" link — still a full navigation by design. +**Phase 2.8q — click-to-copy hashes + styled checksum table** (shipped +2026-05-18): operator: make the checksum hashes click-to-copy (they +were unstyled, overflowing the column). New reusable +`static/js/copy.js` (delegated `[data-copy]`, async Clipboard API + +hidden-textarea fallback, "Copied!" feedback, cache-busted) — generic, +not checksum-specific. `content.j2` wraps each hash in a +`button.copy-hash` (local Jinja macro, DRY). New `.checksum-table` / +`.copy-hash` CSS: `table-layout:fixed` + `word-break:break-all` so the +64-char SHA wraps fully visible instead of truncating; theme-aware +tokens only (no `[data-theme]` overrides → can't re-introduce the +trans-blue-class bug). `/styleguide#copyhash` added. Test: +`test_checksum_report_is_click_to_copy`. + **Phase 2.8p — Checksums as a verifiable page report** (shipped 2026-05-17): operator wants the content page's "Checksums" panel to cover the whole page, not just the product file — diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index a736f20..65802f4 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -711,6 +711,81 @@ div.product-images img.product-main { } /* Prevent content sections from overflowing */ +/* MPS-24: Checksums page report + click-to-copy hashes. Theme-aware + tokens only (they carry dark values) so no [data-theme] overrides + needed. Table layout so the 64-char SHA wraps fully instead of + overflowing the column. */ +details.product-checksums { + margin: var(--space-3, 12px) 0; +} +details.product-checksums > summary { + cursor: pointer; + font-weight: 600; +} +p.checksum-hint { + margin: var(--space-2, 8px) 0; + color: var(--text-muted, #6b7280); +} +table.checksum-table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + margin: 0 0 var(--space-3, 12px) 0; +} +table.checksum-table th, +table.checksum-table td { + text-align: left; + vertical-align: top; + padding: var(--space-2, 8px) var(--space-3, 12px); + border-bottom: 1px solid var(--border-light, #e5e7eb); +} +table.checksum-table th { + color: var(--text-muted, #6b7280); + font-size: var(--type-body-sm-size, 0.875rem); +} +table.checksum-table th:first-child, +table.checksum-table td:first-child { width: 7.5rem; } +table.checksum-table th:nth-child(2), +table.checksum-table td:nth-child(2) { width: 5.5rem; } +button.copy-hash { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: var(--space-2, 8px); + width: 100%; + margin: 0; + padding: var(--space-1, 4px) var(--space-2, 8px); + border: 1px solid var(--border-light, #e5e7eb); + border-radius: var(--radius-sm, 4px); + background: var(--surface-dim, #f9f9fa); + color: var(--text-body, #333); + font-family: var(--font-mono, ui-monospace, Menlo, Consolas, monospace); + font-size: var(--type-body-sm-size, 0.8125rem); + text-align: left; + cursor: pointer; + transition: background-color 150ms ease, border-color 150ms ease; +} +button.copy-hash > code { + word-break: break-all; + background: none; + padding: 0; +} +button.copy-hash:hover, +button.copy-hash:focus-visible { + background: var(--surface-container, #eee); + border-color: var(--border-default, #d1d5db); +} +button.copy-hash.copied { + border-color: var(--color-green, #4d7a1f); +} +span.copy-hash-status { + color: var(--color-green, #4d7a1f); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: var(--type-body-sm-size, 0.75rem); + font-weight: 600; + white-space: nowrap; +} + .product-description { max-width: 100%; overflow-x: auto; diff --git a/make_post_sell/static/js/copy.js b/make_post_sell/static/js/copy.js new file mode 100644 index 0000000..0e07a7b --- /dev/null +++ b/make_post_sell/static/js/copy.js @@ -0,0 +1,71 @@ +/* copy.js — generic click-to-copy for any [data-copy] element. + * + * Capability-driven (CLAUDE.md): without JS the element's text is still + * visible and selectable, so the value is never lost — JS only adds the + * one-click convenience + "Copied!" feedback. + * + * Usage: + * + * + * Delegated listener so elements rendered later still work. Uses the + * async Clipboard API where available, with a hidden-textarea + + * execCommand fallback for older / non-secure contexts. + */ +(function () { + "use strict"; + + function flash(el, msg) { + var s = el.querySelector(".copy-hash-status"); + if (s) s.textContent = msg; + el.classList.add("copied"); + clearTimeout(el.__copyTimer); + el.__copyTimer = setTimeout(function () { + if (s) s.textContent = ""; + el.classList.remove("copied"); + }, 1600); + } + + function legacyCopy(text) { + try { + var ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + var ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch (e) { + return false; + } + } + + function copy(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text).then( + function () { return true; }, + function () { return legacyCopy(text); } + ); + } + return Promise.resolve(legacyCopy(text)); + } + + document.addEventListener( + "click", + function (ev) { + var el = ev.target.closest("[data-copy]"); + if (!el) return; + ev.preventDefault(); + var text = el.getAttribute("data-copy") || ""; + if (!text) return; + copy(text).then(function (ok) { + flash(el, ok ? "Copied!" : "Press Ctrl+C"); + }); + }, + false + ); +})(); diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index ddb5fdd..a0a0d72 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -147,8 +147,15 @@ they see and confirm provenance. #} {% set fc = product.checksums %} {% set tc = product.content_checksums() %} + {%- macro hashcell(value, algo) -%} + + {%- endmacro -%}
Checksums — page report +

Click any hash to copy it.

{% for label, src in [ @@ -157,8 +164,8 @@ ("Title", tc.get("title", {})), ("Description", tc.get("description", {})), ] %} - {% if src.get("sha256") %}{% endif %} - {% if src.get("md5") %}{% endif %} + {% if src.get("sha256") %}{% endif %} + {% if src.get("md5") %}{% endif %} {% endfor %}
AssetAlgorithmHash
{{ label }}SHA-256{{ src["sha256"] }}
{% if not src.get("sha256") %}{{ label }}{% endif %}MD5{{ src["md5"] }}
{{ label }}SHA-256{{ hashcell(src["sha256"], "SHA-256") }}
{% if not src.get("sha256") %}{{ label }}{% endif %}MD5{{ hashcell(src["md5"], "MD5") }}
@@ -219,5 +226,6 @@ function playInline(container, videoUrl) { {% endif %} + {%- endblock -%} diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index 9889afb..0e74640 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -194,6 +194,7 @@ Comments Tag Chips SERP Rail + Click to Copy Toggle Ribbon Task Bar @@ -1215,6 +1216,36 @@ The snippet sets inline margin-left for depth-N replies (depth * 20px). + +
+
Click to Copy
+

+ Generic one-click copy (static/js/copy.js, delegated + on [data-copy]). Capability-driven: without JS the + value is still visible & selectable; JS adds the click + + "Copied!" feedback. Used by the checksum page report on + content.j2. Theme-aware tokens (works in dark mode). +

+
+ + + + +
AssetAlgorithmHash
TitleSHA-256 + +
MD5 + +
+ +
+
+ +
Toggle / Details
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index cc9305f..b9a3949 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -3374,6 +3374,35 @@ class AuthenticatedFunctionalTests(_AuthenticatedBase): return product_id, product_slug + def test_checksum_report_is_click_to_copy(self): + """MPS-24: the Checksums page report renders every hash as a + click-to-copy button (data-copy) and ships the cache-busted + copy.js. Stored file/thumbnail hashes + live title/description + hashes all appear.""" + product_id, slug = self._create_content_with_metadata( + "copy-hash-shop", "Hashing Title", "A description here.", + { + "extensions": {"product": "pdf", "thumbnail1": "jpg"}, + "checksums": { + "product": {"sha256": "a" * 64, "md5": "b" * 32}, + "thumbnail1": {"sha256": "c" * 64, "md5": "d" * 32}, + }, + }, + ) + res = self.testapp.get(f"/c/{product_id}/{slug}", status=200) + body = res.body.decode() + self.assertIn('class="copy-hash"', body) + # Stored file + thumbnail hashes are copyable. + self.assertIn('data-copy="' + "a" * 64 + '"', body) + self.assertIn('data-copy="' + "c" * 64 + '"', body) + # Live title/description hashes are present (computed on render). + import hashlib + title_sha = hashlib.sha256("Hashing Title".encode()).hexdigest() + self.assertIn('data-copy="' + title_sha + '"', body) + # Cache-busted copy.js shipped. + self.assertIn("/static/js/copy.js?v=", body) + self.assertIn("Click any hash to copy", body) + def test_watch_json_includes_karaoke_urls(self): """Watch JSON returns instrumentals/vocals URLs for products with karaoke tracks.""" product_id, _ = self._create_content_with_metadata(