diff --git a/assets/controllers/clipboard_controller.js b/assets/controllers/clipboard_controller.js new file mode 100644 index 0000000..d1fab2d --- /dev/null +++ b/assets/controllers/clipboard_controller.js @@ -0,0 +1,70 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Copies an image to the OS clipboard so it can be pasted into WhatsApp etc. + * + * + * + * 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); + } + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index 6751a2a..039661e 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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; +} diff --git a/src/Controller/ArtController.php b/src/Controller/ArtController.php index 6182feb..55ce8bb 100644 --- a/src/Controller/ArtController.php +++ b/src/Controller/ArtController.php @@ -74,6 +74,12 @@ class ArtController extends AbstractController return $this->render('art/upload.html.twig', ['form' => $form]); } + #[Route('/art/{id}', name: 'app_art_show', requirements: ['id' => '\d+'], methods: ['GET'])] + public function show(Artwork $artwork): Response + { + return $this->render('art/show.html.twig', ['art' => $artwork]); + } + #[Route('/art/{id}/file', name: 'app_art_file', requirements: ['id' => '\d+'], methods: ['GET'])] public function serve(Artwork $artwork): BinaryFileResponse { diff --git a/templates/art/index.html.twig b/templates/art/index.html.twig index c3baad1..f20de51 100644 --- a/templates/art/index.html.twig +++ b/templates/art/index.html.twig @@ -18,26 +18,17 @@