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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -18,26 +18,17 @@
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3">
|
||||
{% for art in artworks %}
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100">
|
||||
<a href="{{ path('app_art_file', {id: art.id}) }}" target="_blank" rel="noopener">
|
||||
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}"
|
||||
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">
|
||||
<a href="{{ path('app_art_show', {id: art.id}) }}" class="card shadow-sm h-100 text-decoration-none text-body">
|
||||
<div class="art-thumb">
|
||||
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy">
|
||||
</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>
|
||||
<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">×</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -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">← 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 %}
|
||||
Reference in New Issue
Block a user