Art: full-image thumbnails, detail page with copy-to-clipboard
- gallery thumbnails now object-fit: contain (whole image, letterboxed),
and a card links to the new detail page instead of the raw file
- /art/{id} detail page: large uncropped image, "Open full size",
"Delete" (moved here from the gallery), and "Copy image"
- clipboard Stimulus controller copies the image to the OS clipboard
(converts non-PNG via canvas) for pasting into WhatsApp etc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* Copies an image to the OS clipboard so it can be pasted into WhatsApp etc.
|
||||
*
|
||||
* <button data-controller="clipboard"
|
||||
* data-clipboard-url-value="/art/5/file"
|
||||
* data-action="clipboard#copy">Copy image</button>
|
||||
*
|
||||
* The Clipboard API only reliably accepts image/png, so non-PNG sources are
|
||||
* redrawn through a canvas first. Needs a secure context (https or localhost).
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = { url: String };
|
||||
|
||||
async copy(event) {
|
||||
const btn = event.currentTarget;
|
||||
|
||||
if (!navigator.clipboard || !window.ClipboardItem) {
|
||||
this.flash(btn, 'Not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
this.flash(btn, 'Copying…', 0);
|
||||
try {
|
||||
const res = await fetch(this.urlValue, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
let blob = await res.blob();
|
||||
if (blob.type !== 'image/png') {
|
||||
blob = await this.toPng(blob);
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
this.flash(btn, 'Copied!');
|
||||
} catch (e) {
|
||||
console.error('clipboard copy failed', e);
|
||||
this.flash(btn, 'Copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
toPng(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('image decode failed'));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
flash(btn, msg, revertAfter = 1800) {
|
||||
if (!btn.dataset.label) btn.dataset.label = btn.textContent;
|
||||
btn.textContent = msg;
|
||||
clearTimeout(this._t);
|
||||
if (revertAfter > 0) {
|
||||
this._t = setTimeout(() => {
|
||||
btn.textContent = btn.dataset.label;
|
||||
}, revertAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,3 +89,26 @@ table.dataTable {
|
||||
.ft-amp {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
/* --- Art gallery ------------------------------------------------------- */
|
||||
.art-thumb {
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
.art-thumb img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain; /* whole image, never cropped */
|
||||
}
|
||||
.art-full img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user