Adiciona widget UserPhotosWidget para gerenciar fotos do usuário

Widget unificado para field_user_photos: exibe tira de thumbnails em
linha com destaque (borda azul) na foto padrão; clicar num thumbnail
seleciona-o como padrão. O campo field_user_default_photo é atualizado
ao salvar. Edição/remoção de mídias individuais ficam a cargo do menu
contextual do Drupal. O hook _site_users_add_default_photo_selector()
é ignorado automaticamente quando o widget está ativo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 10:39:22 -03:00
parent 0b137e8d12
commit 0c7b346530
4 changed files with 370 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
/**
* @file
* Widget de tira de thumbnails com seletor de foto padrão.
*/
/* ---- Título da seção ----------------------------------------------------- */
.user-photos-strip-label {
margin: 0 0 0.5rem;
font-weight: 600;
font-size: 0.9rem;
color: #444;
}
/* ---- Tira de thumbnails -------------------------------------------------- */
.user-photos-strip {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.25rem;
}
.user-photos-empty {
color: #666;
font-style: italic;
}
/* ---- Card de foto -------------------------------------------------------- */
.user-photos-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
/* O .form-type--radio (ou .form-type-radio) envolve input + label.
Tratamos o bloco inteiro como o card clicável. */
.user-photos-item .form-type--radio,
.user-photos-item .form-type-radio {
position: relative;
cursor: pointer;
}
/* Oculta o círculo do radio sem tirá-lo do fluxo de acessibilidade */
.user-photos-item .form-radio {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Label = thumbnail — recebe borda ao ser selecionado */
.user-photos-item .form-type--radio label,
.user-photos-item .form-type-radio label {
display: block;
cursor: pointer;
border: 3px solid transparent;
border-radius: 6px;
padding: 2px;
transition: border-color 0.15s, background-color 0.15s;
}
/* Destaque na foto padrão selecionada */
.user-photos-item .form-type--radio:has(input[type="radio"]:checked) label,
.user-photos-item .form-type-radio:has(input[type="radio"]:checked) label {
border-color: #0678be;
background-color: #e8f4fd;
}
/* Thumbnail contido dentro do label */
.user-photos-item .form-type--radio label .media,
.user-photos-item .form-type-radio label .media,
.user-photos-item .form-type--radio label img,
.user-photos-item .form-type-radio label img {
display: block;
width: 120px;
height: 100px;
object-fit: cover;
border-radius: 3px;
}
/* ---- Área de upload ------------------------------------------------------ */
.user-photos-widget .js-form-item-new-photo,
.user-photos-widget .form-item-new-photo {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}

View File

@@ -3,3 +3,9 @@ user-info-block:
css:
component:
css/site-user-info-block.css: {}
user_photos_widget:
version: 1.x
css:
component:
css/user-photos-widget.css: {}

View File

@@ -327,8 +327,18 @@ function site_users_form_user_register_form_alter(&$form, FormStateInterface $fo
/**
* Adiciona o seletor de foto padrão ao formulário.
*
* Ignorado quando o widget user_photos_widget está ativo para
* field_user_photos, pois ele já gerencia a foto padrão.
*/
function _site_users_add_default_photo_selector(&$form, FormStateInterface $form_state) {
// Verificar se o widget unificado está sendo usado.
$form_display = $form_state->getFormObject()->getFormDisplay($form_state);
$component = $form_display->getComponent('field_user_photos');
if ($component && ($component['type'] ?? '') === 'user_photos_widget') {
return;
}
/** @var \Drupal\user\UserInterface $user */
$user = $form_state->getFormObject()->getEntity();

View File

@@ -0,0 +1,262 @@
<?php
namespace Drupal\site_users\Plugin\Field\FieldWidget;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\file\FileInterface;
use Drupal\media\MediaInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Widget de tira de thumbnails para gerenciar fotos de usuário.
*
* Exibe as fotos em linha; a foto padrão é destacada com borda colorida.
* Clicar em um thumbnail muda a foto padrão. Ao salvar, atualiza
* field_user_default_photo na entidade.
*
* @FieldWidget(
* id = "user_photos_widget",
* label = @Translation("Galeria de fotos com foto padrão"),
* field_types = {
* "entity_reference"
* }
* )
*/
class UserPhotosWidget extends WidgetBase {
public function __construct(
$plugin_id,
$plugin_definition,
FieldDefinitionInterface $field_definition,
array $settings,
array $third_party_settings,
protected EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('entity_type.manager'),
);
}
public static function isApplicable(FieldDefinitionInterface $field_definition): bool {
$storage = $field_definition->getFieldStorageDefinition();
if ($storage->getSetting('target_type') !== 'media') {
return FALSE;
}
$handler_settings = $field_definition->getSetting('handler_settings');
return !empty($handler_settings['target_bundles']['image']);
}
/**
* {@inheritdoc}
*/
public function form(FieldItemListInterface $items, array &$form, FormStateInterface $form_state, $get_delta = NULL): array {
$field_name = $this->fieldDefinition->getName();
$parents = $form['#parents'] ?? [];
$element = [
'#field_name' => $field_name,
'#field_parents' => $parents,
'#parents' => array_merge($parents, [$field_name]),
'#array_parents' => array_merge($form['#array_parents'] ?? [], [$field_name]),
'#tree' => TRUE,
'#attached' => ['library' => ['site_users/user_photos_widget']],
'#attributes' => ['class' => ['user-photos-widget']],
];
$entity = $items->getEntity();
// Foto padrão atual: na entidade salva, ou primeira disponível.
$current_default = '';
if ($entity->hasField('field_user_default_photo') && !$entity->get('field_user_default_photo')->isEmpty()) {
$current_default = (string) $entity->get('field_user_default_photo')->target_id;
}
// Coleta mídias existentes na ordem atual do campo.
$medias = [];
foreach ($items as $item) {
if (!$item->isEmpty() && $item->entity instanceof MediaInterface) {
$medias[(string) $item->entity->id()] = $item->entity;
}
}
if ($current_default === '' || !isset($medias[$current_default])) {
$current_default = (string) (array_key_first($medias) ?? '');
}
// Caminho compartilhado por todos os radios do grupo "foto padrão".
// Todos os radios com o mesmo #parents geram o mesmo HTML name, formando
// um grupo. O Drupal calcula #checked automaticamente comparando
// #return_value com o valor submetido / #default_value.
$radio_parents = array_merge($element['#parents'], ['default_photo']);
// --- Tira de thumbnails ---
if (!empty($medias)) {
$element['photos_label'] = [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => $this->t('Selecionar foto padrão'),
'#attributes' => ['class' => ['user-photos-strip-label']],
];
}
$element['photos'] = [
'#type' => 'container',
'#attributes' => ['class' => ['user-photos-strip']],
];
if (empty($medias)) {
$element['photos']['empty'] = [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => $this->t('Nenhuma foto adicionada ainda.'),
'#attributes' => ['class' => ['user-photos-empty']],
];
}
$view_builder = $this->entityTypeManager->getViewBuilder('media');
foreach ($medias as $mid_str => $media) {
// Cada foto é um card .user-photos-item.
$element['photos'][$mid_str] = [
'#type' => 'container',
'#attributes' => ['class' => ['user-photos-item']],
];
// Radio oculto via CSS: o título (thumbnail) torna-se o <label> clicável.
// Todos os radios compartilham #parents → mesmo HTML name → grupo rádio.
// Drupal calcula #checked comparando #return_value com o valor do grupo.
$element['photos'][$mid_str]['default'] = [
'#type' => 'radio',
'#title' => $view_builder->view($media, 'thumbnail'),
'#title_display' => 'after',
'#return_value' => $mid_str,
'#default_value' => $current_default,
'#parents' => $radio_parents,
];
}
// --- Upload de nova foto ---
$uid = $entity->id() ?: 'new';
$max_count = \Drupal::config('site_users.settings')->get('photos.max_count') ?? 5;
$element['new_photo'] = [
'#type' => 'managed_file',
'#title' => $this->t('Adicionar foto'),
'#description' => $this->t(
'Formatos aceitos: JPEG, PNG, GIF, WebP. Máximo @max fotos no total.',
['@max' => $max_count],
),
'#upload_location' => 'public://user-photos/' . $uid . '/',
'#upload_validators' => [
'FileExtension' => ['extensions' => 'jpg jpeg png gif webp'],
'FileSizeLimit' => ['fileLimit' => 10 * 1024 * 1024],
],
'#multiple' => FALSE,
];
return $element;
}
/**
* {@inheritdoc}
*/
public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state): void {
$field_name = $this->fieldDefinition->getName();
$parents = $form['#parents'] ?? [];
$widget_values = NestedArray::getValue(
$form_state->getValues(),
array_merge($parents, [$field_name]),
) ?? [];
// IDs atuais na ordem do campo.
$current_ids = [];
foreach ($items as $item) {
if (!$item->isEmpty()) {
$current_ids[] = (string) $item->target_id;
}
}
$remaining_ids = $current_ids;
// Processa upload de nova foto.
$new_fids = $widget_values['new_photo'] ?? [];
if (!empty($new_fids)) {
$fid = is_array($new_fids) ? (int) reset($new_fids) : (int) $new_fids;
if ($fid) {
$new_mid = $this->createMediaFromFile($fid, $items->getEntity());
if ($new_mid) {
$remaining_ids[] = (string) $new_mid;
}
}
}
// Atualiza field_user_photos.
$values = array_map(fn($id) => ['target_id' => (int) $id], $remaining_ids);
$items->setValue($values);
$items->filterEmptyItems();
// Atualiza field_user_default_photo.
// O valor do grupo de radios está em default_photo (caminho compartilhado).
$default_photo_id = (string) ($widget_values['default_photo'] ?? '');
$entity = $items->getEntity();
if ($entity->hasField('field_user_default_photo')) {
$entity->set('field_user_default_photo', $default_photo_id ?: NULL);
}
}
/**
* Cria uma entidade de mídia do tipo 'image' a partir de um FID.
*/
protected function createMediaFromFile(int $fid, EntityInterface $owner): ?int {
$file = $this->entityTypeManager->getStorage('file')->load($fid);
if (!$file instanceof FileInterface) {
return NULL;
}
$file->setPermanent();
$file->save();
$media_type = $this->entityTypeManager->getStorage('media_type')->load('image');
if (!$media_type) {
return NULL;
}
$source_field = $media_type->getSource()->getConfiguration()['source_field'];
$media = $this->entityTypeManager->getStorage('media')->create([
'bundle' => 'image',
'uid' => $owner->id() ?: \Drupal::currentUser()->id(),
'status' => 1,
$source_field => [
'target_id' => $fid,
'alt' => $file->getFilename(),
],
]);
$media->save();
return (int) $media->id();
}
/**
* {@inheritdoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state): array {
return [];
}
}