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:
Frank
2026-09-06 16:53:49 +02:00
co-authored by Claude Sonnet 5
parent f04c8ccc51
commit ed22b81e97
5 changed files with 147 additions and 19 deletions
@@ -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);
}
}
}
+23
View File
@@ -89,3 +89,26 @@ table.dataTable {
.ft-amp { .ft-amp {
color: var(--bs-secondary-color); 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;
}
+6
View File
@@ -74,6 +74,12 @@ class ArtController extends AbstractController
return $this->render('art/upload.html.twig', ['form' => $form]); 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'])] #[Route('/art/{id}/file', name: 'app_art_file', requirements: ['id' => '\d+'], methods: ['GET'])]
public function serve(Artwork $artwork): BinaryFileResponse public function serve(Artwork $artwork): BinaryFileResponse
{ {
+10 -19
View File
@@ -18,26 +18,17 @@
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3">
{% for art in artworks %} {% for art in artworks %}
<div class="col"> <div class="col">
<div class="card shadow-sm h-100"> <a href="{{ path('app_art_show', {id: art.id}) }}" class="card shadow-sm h-100 text-decoration-none text-body">
<a href="{{ path('app_art_file', {id: art.id}) }}" target="_blank" rel="noopener"> <div class="art-thumb">
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" <img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy">
class="card-img-top object-fit-cover" style="height: 180px;"
loading="lazy">
</a>
<div class="card-body py-2 d-flex justify-content-between align-items-start gap-2">
<div class="min-w-0">
<div class="fw-semibold text-truncate" title="{{ art.name }}">{{ art.name }}</div>
<div class="text-body-secondary small">
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
</div>
</div>
<form method="post" action="{{ path('app_art_delete', {id: art.id}) }}"
onsubmit="return confirm('Delete “{{ art.name|e('js') }}”?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete-art-' ~ art.id) }}">
<button class="btn btn-sm btn-outline-danger" title="Delete">&times;</button>
</form>
</div> </div>
</div> <div class="card-body py-2">
<div class="fw-semibold text-truncate" title="{{ art.name }}">{{ art.name }}</div>
<div class="text-body-secondary small">
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
</div>
</div>
</a>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+38
View File
@@ -0,0 +1,38 @@
{% extends 'base.html.twig' %}
{% block title %}{{ art.name }} · Art · dmtools{% endblock %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
<div>
<a href="{{ path('app_art') }}" class="text-decoration-none small">&larr; Art</a>
<h1 class="h3 mb-0">{{ art.name }}</h1>
<span class="text-body-secondary small">
{{ art.uploadedAt|date('Y-m-d H:i') }} · {{ (art.size / 1024)|round }} KB
{%- if art.originalName %} · {{ art.originalName }}{% endif %}
</span>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-controller="clipboard"
data-clipboard-url-value="{{ path('app_art_file', {id: art.id}) }}"
data-action="clipboard#copy">Copy image</button>
<a href="{{ path('app_art_file', {id: art.id}) }}" target="_blank" rel="noopener"
class="btn btn-outline-secondary btn-sm">Open full size</a>
<form method="post" action="{{ path('app_art_delete', {id: art.id}) }}"
onsubmit="return confirm('Delete “{{ art.name|e('js') }}”?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete-art-' ~ art.id) }}">
<button class="btn btn-outline-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body text-center art-full">
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}">
</div>
</div>
{% endblock %}