feat: MPS-24 — click-to-copy checksum hashes + styled report table
Operator: make the checksum report hashes click-to-copy (they were unstyled and overflowing the column). - New reusable static/js/copy.js: delegated [data-copy] handler, async Clipboard API + hidden-textarea/execCommand fallback, transient 'Copied!' feedback. Generic (not checksum-specific) so other ad-hoc inline copy buttons can migrate later. Cache-busted (?v=git_hash). - content.j2: each hash wrapped in button.copy-hash via a local Jinja macro (DRY). Capability-driven: no-JS the hash text is still visible + selectable. - 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 (dark-mode correct, no [data-theme] override that could regress like the trans-blue bug). - /styleguide#copyhash added. Test: test_checksum_report_is_click_to_copy. 1151 passed. - Docs: mps-24.md Phase 2.8q.
This commit is contained in:
parent
52e6c57145
commit
5888848999
6 changed files with 229 additions and 2 deletions
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
71
make_post_sell/static/js/copy.js
Normal file
71
make_post_sell/static/js/copy.js
Normal file
|
|
@ -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:
|
||||
* <button type="button" data-copy="VALUE">
|
||||
* <code>VALUE</code><span class="copy-hash-status"></span>
|
||||
* </button>
|
||||
*
|
||||
* 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
|
||||
);
|
||||
})();
|
||||
|
|
@ -147,8 +147,15 @@
|
|||
they see and confirm provenance. #}
|
||||
{% set fc = product.checksums %}
|
||||
{% set tc = product.content_checksums() %}
|
||||
{%- macro hashcell(value, algo) -%}
|
||||
<button type="button" class="copy-hash" data-copy="{{ value }}"
|
||||
title="Click to copy {{ algo }}" aria-label="Copy {{ algo }} hash">
|
||||
<code>{{ value }}</code><span class="copy-hash-status" aria-hidden="true"></span>
|
||||
</button>
|
||||
{%- endmacro -%}
|
||||
<details class="product-checksums">
|
||||
<summary>Checksums — page report</summary>
|
||||
<p class="type-body-sm checksum-hint">Click any hash to copy it.</p>
|
||||
<table class="checksum-table">
|
||||
<tr><th>Asset</th><th>Algorithm</th><th>Hash</th></tr>
|
||||
{% for label, src in [
|
||||
|
|
@ -157,8 +164,8 @@
|
|||
("Title", tc.get("title", {})),
|
||||
("Description", tc.get("description", {})),
|
||||
] %}
|
||||
{% if src.get("sha256") %}<tr><td>{{ label }}</td><td>SHA-256</td><td><code>{{ src["sha256"] }}</code></td></tr>{% endif %}
|
||||
{% if src.get("md5") %}<tr><td>{% if not src.get("sha256") %}{{ label }}{% endif %}</td><td>MD5</td><td><code>{{ src["md5"] }}</code></td></tr>{% endif %}
|
||||
{% if src.get("sha256") %}<tr><td>{{ label }}</td><td>SHA-256</td><td>{{ hashcell(src["sha256"], "SHA-256") }}</td></tr>{% endif %}
|
||||
{% if src.get("md5") %}<tr><td>{% if not src.get("sha256") %}{{ label }}{% endif %}</td><td>MD5</td><td>{{ hashcell(src["md5"], "MD5") }}</td></tr>{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</details>
|
||||
|
|
@ -219,5 +226,6 @@ function playInline(container, videoUrl) {
|
|||
<script src="/static/js/watch.js?v={{ request.git_hash }}"></script>
|
||||
{% endif %}
|
||||
<script src="/static/js/signals.js?v={{ request.git_hash }}"></script>
|
||||
<script src="/static/js/copy.js?v={{ request.git_hash }}" defer></script>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@
|
|||
<a href="#comments">Comments</a>
|
||||
<a href="#tagchips">Tag Chips</a>
|
||||
<a href="#serprail">SERP Rail</a>
|
||||
<a href="#copyhash">Click to Copy</a>
|
||||
<a href="#toggle">Toggle</a>
|
||||
<a href="#ribbon">Ribbon</a>
|
||||
<a href="#taskbar">Task Bar</a>
|
||||
|
|
@ -1215,6 +1216,36 @@ The snippet sets inline margin-left for depth-N replies (depth * 20px).</div>
|
|||
</div>
|
||||
|
||||
|
||||
<!-- ======== CLICK TO COPY ======== -->
|
||||
<div class="sg-section" id="copyhash">
|
||||
<div class="sg-section-title">Click to Copy</div>
|
||||
<p style="max-width:600px;line-height:1.6;">
|
||||
Generic one-click copy (<code>static/js/copy.js</code>, delegated
|
||||
on <code>[data-copy]</code>). Capability-driven: without JS the
|
||||
value is still visible & selectable; JS adds the click +
|
||||
"Copied!" feedback. Used by the checksum page report on
|
||||
<code>content.j2</code>. Theme-aware tokens (works in dark mode).
|
||||
</p>
|
||||
<div class="sg-demo">
|
||||
<table class="checksum-table" style="max-width:520px;">
|
||||
<tr><th>Asset</th><th>Algorithm</th><th>Hash</th></tr>
|
||||
<tr><td>Title</td><td>SHA-256</td><td>
|
||||
<button type="button" class="copy-hash"
|
||||
data-copy="4fde23aa35bb9350f14f2bdd4e88b098e03af060608fdd588">
|
||||
<code>4fde23aa35bb9350f14f2bdd4e88b098e03af060608fdd588</code><span class="copy-hash-status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</td></tr>
|
||||
<tr><td></td><td>MD5</td><td>
|
||||
<button type="button" class="copy-hash" data-copy="c6acda7417b71307f953d53d2164c852">
|
||||
<code>c6acda7417b71307f953d53d2164c852</code><span class="copy-hash-status" aria-hidden="true"></span>
|
||||
</button>
|
||||
</td></tr>
|
||||
</table>
|
||||
<script src="/static/js/copy.js?v={{ request.git_hash }}" defer></script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ======== TOGGLE ======== -->
|
||||
<div class="sg-section" id="toggle">
|
||||
<div class="sg-section-title">Toggle / Details</div>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue