Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions assets/controllers/pages/part_picture_fan_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import {Controller} from "@hotwired/stimulus";
import {Carousel} from "bootstrap";

import "../../css/components/part_picture_fan.css";

/**
* Fans out all pictures of a part next to the picture carousel of the part info page, while the mouse is over it.
* The fanning itself is pure CSS (see part_picture_fan.css); this controller shows the clicked picture in the
* carousel and, if an URL is given (the user may edit the part), saves it as the preview picture of the part.
*/
export default class extends Controller {
static targets = ["carousel", "item"];

static values = {
url: String,
csrf: String,
master: Number,
};

connect() {
this._onSlide = (event) => this._markActive(event.to);
this.carouselTarget.addEventListener("slide.bs.carousel", this._onSlide);
}

disconnect() {
this.carouselTarget.removeEventListener("slide.bs.carousel", this._onSlide);
}

select(event) {
const item = event.currentTarget;
const index = parseInt(item.dataset.index, 10);

Carousel.getOrCreateInstance(this.carouselTarget).to(index);
this._markActive(index);

const attachmentId = parseInt(item.dataset.attachmentId ?? "", 10);
if (!this.hasUrlValue || !this.urlValue || Number.isNaN(attachmentId) || attachmentId === this.masterValue) {
return;
}

this._save(item, attachmentId);
}

_markActive(index) {
this.itemTargets.forEach((item) => {
item.classList.toggle("active", parseInt(item.dataset.index, 10) === index);
});
}

_save(item, attachmentId) {
const body = new FormData();
body.append("attachment", attachmentId);
body.append("_token", this.csrfValue);

item.classList.add("is-saving");
fetch(this.urlValue, {
method: "POST",
body,
headers: {"X-Requested-With": "XMLHttpRequest"},
})
.then((response) => response.json().then((data) => ({ok: response.ok, data})))
.then(({ok, data}) => {
if (!ok || !data || !data.success) {
throw new Error("Unexpected response");
}
this.masterValue = attachmentId;
this.itemTargets.forEach((other) => other.classList.toggle("is-master", other === item));
})
.catch((error) => {
console.warn("Could not save the preview picture of the part", error);
})
.finally(() => {
item.classList.remove("is-saving");
});
}
}
147 changes: 147 additions & 0 deletions assets/css/components/part_picture_fan.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

/* Picture fan of the part info page: hovering the picture fans all pictures of the part out to its right. */

.part-picture-fan {
--part-picture-fan-size: 160px;
--part-picture-fan-gap: .5rem;
position: relative;
}

/* Hidden by default: devices without a real hover (touch) and narrow layouts just keep the plain carousel. */
.part-picture-fan-strip {
display: none;
}

@media (hover: hover) and (pointer: fine) and (min-width: 768px) {
.part-picture-fan-strip {
display: block;
position: absolute;
top: 50%;
left: 100%;
z-index: 1010;
/* The padding bridges the gap to the picture, so the mouse can travel onto the fan without leaving it. */
padding-left: var(--part-picture-fan-gap);
transform: translateY(-50%);
visibility: hidden;
/* The fan is a mouse-only shortcut (hidden elements can not be focused), the carousel controls stay usable by keyboard. */
pointer-events: none;
transition: visibility 0s linear .25s;
}

.part-picture-fan-items {
display: flex;
flex-wrap: wrap;
gap: var(--part-picture-fan-gap);
/* Four pictures per row, further ones wrap onto the next rows. */
width: max-content;
max-width: min(calc(4 * var(--part-picture-fan-size) + 5 * var(--part-picture-fan-gap) + 2px), 60vw);
max-height: 80vh;
overflow-y: auto;
padding: var(--part-picture-fan-gap);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius-lg);
box-shadow: var(--bs-box-shadow);
opacity: 0;
transition: opacity .2s ease-in-out;
}

.part-picture-fan-item {
flex: 0 0 auto;
position: relative;
width: var(--part-picture-fan-size);
height: var(--part-picture-fan-size);
padding: 0;
border: 0;
border-radius: var(--bs-border-radius);
background: none;
cursor: pointer;
opacity: 0;
/* Collapsed, the pictures are stacked behind the main picture; later ones travel further when fanning out. */
transform: translateX(calc(min(var(--fan-index, 0), 5) * -2.5rem - 3rem)) scale(.5) rotate(-10deg);
transition: transform .25s cubic-bezier(.2, .8, .3, 1.2), opacity .2s ease-in-out, box-shadow .15s ease-in-out;
}

.part-picture-fan-item img {
width: 100%;
height: 100%;
object-fit: contain;
}

/* With the fan next to it, the picture stays as it is: no dark overlay with the caption while hovering. */
.part-picture-fan[data-controller] .carousel .mask,
.part-picture-fan[data-controller] .carousel .carousel-caption-hover {
display: none;
}

.part-picture-fan:hover .part-picture-fan-strip {
visibility: visible;
pointer-events: auto;
transition-delay: 0s;
}

.part-picture-fan:hover .part-picture-fan-items {
opacity: 1;
}

.part-picture-fan:hover .part-picture-fan-item {
opacity: 1;
transform: none;
transition-delay: calc(min(var(--fan-index, 0), 11) * 25ms), calc(min(var(--fan-index, 0), 11) * 25ms), 0s;
}

/* The picture currently shown in the main slot */
.part-picture-fan-item.active {
box-shadow: 0 0 0 2px var(--bs-primary);
}

.part-picture-fan:hover .part-picture-fan-item:hover {
z-index: 1;
box-shadow: 0 0 0 3px var(--bs-primary), var(--bs-box-shadow);
transform: scale(1.12);
transition-delay: 0s;
}

/* The picture saved as preview picture of the part */
.part-picture-fan-item.is-master::after {
content: "";
position: absolute;
top: 4px;
right: 4px;
width: 10px;
height: 10px;
border: 2px solid var(--bs-body-bg);
border-radius: 50%;
background-color: var(--bs-success);
}

.part-picture-fan-item.is-saving {
cursor: progress;
}
}

@media (prefers-reduced-motion: reduce) {
.part-picture-fan-strip,
.part-picture-fan-items,
.part-picture-fan-item {
transition: none !important;
}
}
34 changes: 34 additions & 0 deletions src/Controller/PartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,40 @@ public function setEda(Part $part, Request $request): Response
: $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}

/**
* Makes one of the part's own picture attachments its preview (master picture) attachment. Used by the
* picture fan on the part info page, where a click on a picture selects it.
*/
#[Route(path: '/{id}/set_master_picture', name: 'part_set_master_picture', methods: ['POST'])]
public function setMasterPicture(Part $part, Request $request): Response
{
$this->denyAccessUnlessGranted('edit', $part);

if (!$this->isCsrfTokenValid('set_master_picture' . $part->getID(), $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token');
}

$attachment_id = $request->request->getInt('attachment');
$attachment = null;
//Like in the edit form, only a picture attachment of the part itself can become its preview
foreach ($part->getAttachments() as $candidate) {
if ($candidate->getID() === $attachment_id && $candidate->isPicture()) {
$attachment = $candidate;
break;
}
}

if ($attachment === null) {
return $this->json(['success' => false], Response::HTTP_UNPROCESSABLE_ENTITY);
}

$part->setMasterPictureAttachment($attachment);
$this->commentHelper->setMessage('Preview picture selected on info page');
$this->em->flush();

return $this->json(['success' => true, 'attachment' => $attachment->getID()]);
}

/**
* Returns the outcome of a generate-image request as JSON (for the modal/AJAX flow) or as a
* flash + redirect (for a normal form submit).
Expand Down
30 changes: 29 additions & 1 deletion templates/parts/info/_picture.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
{% if not pictures is empty %}
{# <img src="{{ part.masterPictureAttachment | entityURL('file_view') }}" class="img-fluid img-thumbnail bg-light" alt="Part main image" height="300" width="300"> #}

<div id="pictureCarousel" class="carousel slide mb-2" data-bs-interval="false" data-bs-ride="false">
{# With more than one picture, hovering the picture fans all of them out next to it (see part_picture_fan_controller.js) #}
{% set fan_enabled = pictures | length > 1 %}
{# A click in the fan is only saved as the new preview picture, if the user is allowed to edit the (current version of the) part #}
{% set fan_persist = fan_enabled and part.id is not null and timeTravel is null and is_granted('edit', part) %}
<div class="part-picture-fan mb-2" {% if fan_enabled %}{{ stimulus_controller('pages/part_picture_fan', fan_persist ? {
url: path('part_set_master_picture', {id: part.id}),
csrf: csrf_token('set_master_picture' ~ part.id),
master: part.masterPictureAttachment ? part.masterPictureAttachment.id : 0,
} : {}) }}{% endif %}>
<div id="pictureCarousel" class="carousel slide" data-bs-interval="false" data-bs-ride="false" {% if fan_enabled %}{{ stimulus_target('pages/part_picture_fan', 'carousel') }}{% endif %}>
<div class="carousel-indicators">
{% for pic in pictures %}
<button type="button" data-bs-target="#pictureCarousel" data-bs-slide-to="{{ loop.index0 }}" {% if loop.first %}class="active" aria-current="true"{% endif %} aria-label="{{ pic.name }}"></button>
Expand Down Expand Up @@ -38,6 +47,25 @@
</button>
{% endif %}
</div>
{% if fan_enabled %}
<div class="part-picture-fan-strip" role="group" aria-label="{% trans %}part.info.picture_fan.label{% endtrans %}">
<div class="part-picture-fan-items">
{% for pic in pictures %}
{# Only pictures of the part itself can become its preview picture, not the ones inherited from its footprint, category, etc. #}
{% set pic_persist = fan_persist and pic.element is same as(part) %}
<button type="button" class="part-picture-fan-item {% if loop.first %}active{% endif %} {% if part.masterPictureAttachment is same as(pic) %}is-master{% endif %}"
style="--fan-index: {{ loop.index0 }};"
aria-label="{{ pic.name }}"
{{ stimulus_target('pages/part_picture_fan', 'item') }}
{{ stimulus_action('pages/part_picture_fan', 'select', 'click') }}
data-index="{{ loop.index0 }}" {% if pic_persist %}data-attachment-id="{{ pic.id }}"{% endif %}>
<img class="img-thumbnail bg-light" src="{{ attachment_thumbnail(pic, 'thumbnail_md') ?? entity_url(pic, 'file_view') }}" alt="" loading="lazy">
</button>
{% endfor %}
</div>
</div>
{% endif %}
</div>

{% else %}
<div class="position-relative d-inline-block mb-2 part-image-placeholder">
Expand Down
6 changes: 6 additions & 0 deletions translations/messages.en.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,12 @@ Sub elements will be moved upwards.</target>
<target>Previous picture</target>
</segment>
</unit>
<unit id="kEMmPaA" name="part.info.picture_fan.label">
<segment state="translated">
<source>part.info.picture_fan.label</source>
<target>All pictures of this part</target>
</segment>
</unit>
<unit id="pfbjbna" name="part.info.next_picture">
<segment state="translated">
<source>part.info.next_picture</source>
Expand Down
Loading