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 {
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;"
|
</div>
|
||||||
loading="lazy">
|
<div class="card-body py-2">
|
||||||
</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="fw-semibold text-truncate" title="{{ art.name }}">{{ art.name }}</div>
|
||||||
<div class="text-body-secondary small">
|
<div class="text-body-secondary small">
|
||||||
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
|
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ path('app_art_delete', {id: art.id}) }}"
|
</a>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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