v0.3.8-pre.00-fix.001
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/frontend/sass/_app.scss
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
@@ -90,3 +90,28 @@ body {
|
||||
.app-store-table-container {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
|
||||
.app-copyable-long-text {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: .375rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-copyable-long-text__value {
|
||||
cursor: help;
|
||||
display: inline-block;
|
||||
max-width: 24ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-copyable-long-text__copy {
|
||||
flex: 0 0 auto;
|
||||
line-height: 1;
|
||||
padding: .25rem .375rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-store-desk/frontend/ts/main.ts
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
import DataTable from "datatables.net-bs5";
|
||||
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
|
||||
@@ -50,6 +50,102 @@ const viewTitles: Record<ViewId, string> = {
|
||||
let transactionTable: StoreDataTable | null = null;
|
||||
let accountTable: StoreDataTable | null = null;
|
||||
|
||||
const COPY_FEEDBACK_MILLISECONDS = 1200;
|
||||
const LONG_TEXT_HEAD_CHARACTERS = 12;
|
||||
const LONG_TEXT_TAIL_CHARACTERS = 8;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function truncateLongText(value: string): string {
|
||||
const threshold = LONG_TEXT_HEAD_CHARACTERS + LONG_TEXT_TAIL_CHARACTERS + 1;
|
||||
if (value.length <= threshold) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, LONG_TEXT_HEAD_CHARACTERS)}…${value.slice(-LONG_TEXT_TAIL_CHARACTERS)}`;
|
||||
}
|
||||
|
||||
function renderCopyableLongText(value: string, fieldId: string): string {
|
||||
const escapedValue = escapeHtml(value);
|
||||
const escapedVisible = escapeHtml(truncateLongText(value));
|
||||
const escapedFieldId = escapeHtml(fieldId);
|
||||
return `<span class="app-copyable-long-text"><span class="app-copyable-long-text__value font-monospace" title="${escapedValue}">${escapedVisible}</span><button class="btn btn-sm btn-outline-secondary app-copyable-long-text__copy" type="button" data-copy-long-text="${escapedValue}" data-copy-field="${escapedFieldId}" title="Copier la valeur complète" aria-label="Copier la valeur complète"><i class="fa-regular fa-copy" aria-hidden="true"></i></button></span>`;
|
||||
}
|
||||
|
||||
function setCopyableLongText(elementId: string, value: string, fieldId: string): void {
|
||||
const element = document.querySelector<HTMLElement>(`#${elementId}`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.replaceChildren();
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = "app-copyable-long-text";
|
||||
const text = document.createElement("span");
|
||||
text.className = "app-copyable-long-text__value font-monospace";
|
||||
text.textContent = truncateLongText(value);
|
||||
text.title = value;
|
||||
const button = document.createElement("button");
|
||||
button.className = "btn btn-sm btn-outline-secondary app-copyable-long-text__copy";
|
||||
button.type = "button";
|
||||
button.dataset.copyLongText = value;
|
||||
button.dataset.copyField = fieldId;
|
||||
button.title = "Copier la valeur complète";
|
||||
button.setAttribute("aria-label", "Copier la valeur complète");
|
||||
button.innerHTML = '<i class="fa-regular fa-copy" aria-hidden="true"></i>';
|
||||
wrapper.append(text, button);
|
||||
element.append(wrapper);
|
||||
}
|
||||
|
||||
async function writeClipboardText(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to the document copy command for WebViews without Clipboard API permission.
|
||||
}
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-10000px";
|
||||
textarea.style.top = "-10000px";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand("copy");
|
||||
textarea.remove();
|
||||
return copied;
|
||||
}
|
||||
|
||||
async function copyLongText(button: HTMLButtonElement, value: string, fieldId: string): Promise<void> {
|
||||
frontendDebug("main", "Store Desk long text copy requested", { fieldId });
|
||||
const copied = await writeClipboardText(value);
|
||||
if (!copied) {
|
||||
frontendError("main", "Store Desk long text copy failed", { fieldId });
|
||||
return;
|
||||
}
|
||||
const icon = button.querySelector<HTMLElement>("i");
|
||||
const previousClassName = icon?.className ?? "";
|
||||
if (icon) {
|
||||
icon.className = "fa-solid fa-check";
|
||||
}
|
||||
button.title = "Copié";
|
||||
frontendDebug("main", "Store Desk long text copy completed", { fieldId });
|
||||
window.setTimeout(() => {
|
||||
if (icon) {
|
||||
icon.className = previousClassName;
|
||||
}
|
||||
button.title = "Copier la valeur complète";
|
||||
}, COPY_FEEDBACK_MILLISECONDS);
|
||||
}
|
||||
|
||||
function isViewId(value: string | undefined): value is ViewId {
|
||||
return value === "overview" || value === "transactions" || value === "accounts" || value === "diagnostics";
|
||||
}
|
||||
@@ -187,20 +283,20 @@ function initializeTransactionTable(): void {
|
||||
},
|
||||
autoWidth: false,
|
||||
columns: [
|
||||
{ className: "font-monospace", data: "signature" },
|
||||
{ data: "signature", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-signature") : String(data)) },
|
||||
{ className: "font-monospace", data: "slotDecimal" },
|
||||
{ data: "blockTimeUnixMillisDecimal", defaultContent: "—" },
|
||||
{ data: "formatId" },
|
||||
{ data: "formatVersion" },
|
||||
{ data: "payloadSizeDecimal", defaultContent: "—" },
|
||||
{ className: "font-monospace", data: "contentHash" },
|
||||
{ data: "contentHash", render: (data, type) => (type === "display" ? renderCopyableLongText(String(data), "transaction-content-hash") : String(data)) },
|
||||
{ data: "retentionState", render: data => retentionBadge(String(data)) },
|
||||
{
|
||||
data: null,
|
||||
defaultContent: "",
|
||||
render: (_data, _type, row) => {
|
||||
const transaction = row as StoreTransactionRowDto;
|
||||
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${transaction.signature}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
|
||||
return `<button class="btn btn-sm btn-outline-primary" type="button" data-transaction-detail="${escapeHtml(transaction.signature)}"><i class="fa-solid fa-magnifying-glass me-1" aria-hidden="true"></i>Détail</button>`;
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -335,11 +431,11 @@ function clearTransactionDetail(): void {
|
||||
}
|
||||
|
||||
function renderTransactionDetail(detail: StoreTransactionDetailDto): void {
|
||||
setText("transactionDetailSignature", detail.signature);
|
||||
setCopyableLongText("transactionDetailSignature", detail.signature, "transaction-detail-signature");
|
||||
setText("transactionDetailSlot", detail.slotDecimal);
|
||||
setText("transactionDetailBlockTime", detail.blockTimeUnixMillisDecimal ?? "—");
|
||||
setText("transactionDetailFormat", `${detail.formatId} v${detail.formatVersion}`);
|
||||
setText("transactionDetailContentHash", detail.contentHash);
|
||||
setCopyableLongText("transactionDetailContentHash", detail.contentHash, "transaction-detail-content-hash");
|
||||
setText("transactionDetailRetention", detail.retentionState);
|
||||
setText("transactionDetailPayloadSize", detail.payloadSizeDecimal ?? "—");
|
||||
setText("transactionDetailPayloadPreview", detail.payloadPreviewHex ?? "Payload non disponible dans cet état de rétention.");
|
||||
@@ -352,7 +448,7 @@ async function openTransactionDetail(signature: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
clearTransactionDetail();
|
||||
setText("transactionDetailSignature", signature);
|
||||
setCopyableLongText("transactionDetailSignature", signature, "transaction-detail-signature");
|
||||
frontendDebug("main", "Store Desk transaction detail load requested");
|
||||
const modal = Modal.getOrCreateInstance(modalElement);
|
||||
modal.show();
|
||||
@@ -410,6 +506,15 @@ function installInteractions(): void {
|
||||
if (!(eventTarget instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const copyButton = eventTarget.closest<HTMLButtonElement>("[data-copy-long-text]");
|
||||
if (copyButton) {
|
||||
const value = copyButton.dataset.copyLongText;
|
||||
const fieldId = copyButton.dataset.copyField ?? "long-text";
|
||||
if (value) {
|
||||
void copyLongText(copyButton, value, fieldId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const detailButton = eventTarget.closest<HTMLElement>("[data-transaction-detail]");
|
||||
if (detailButton) {
|
||||
const signature = detailButton.dataset.transactionDetail;
|
||||
|
||||
Reference in New Issue
Block a user