---
id: SVC-GALLERY-UI
tipo: ui
onda: 2
status: concluido
unidades: [SVC-GALLERY]
fontes:
  - V2/Services/Gallery/Views/
depende_de: [SVC-GALLERY, SVC-MEDIA, UI-CMS]
consumidores: [MOD-LAYOUTS-BASICPAGE, MOD-SUPPORT-STRUCTURALLANDINGEDITOR, SVC-LANDINGSECTIONSEDITOR, SVC-CONTENTRICHEDITOR, V2/layouts/cms/layouts/app.blade.php]
criticidade: alta
dono: SvcGallery
---

# UI Gallery — contrato do macro-componente `gallery-picker`

> Este documento cobre o contrato de tela, DOM, eventos, estado e ciclo de vida do picker.
> O núcleo lógico (tabela, escopo por módulo, upload, rotação, exclusão) está em
> [`spec.md`](spec.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Abre um modal de página única com 4 segmentos (upload, metadados, filtros, grade), permite enviar/recortar/rotacionar/arquivar mídia e devolve a mídia escolhida a um callback JavaScript. |
| Entrada | `@include('v2-service-gallery::components.gallery-picker', [...])`; evento `window` `lotep:open-gallery-picker-<pickerId>` com `detail.callback`. |
| Saída | `callback(id, url, alt, mimeType, description)`; requisições HTTP para `v2.gallery.index/store/update/rotate/archive/download`; upload de arquivo/crop. |
| Efeitos colaterais | Injeta CSS/JS no layout (`@push('scripts')`), carrega Cropper.js uma vez (`$GLOBALS`), cria instância Bootstrap do modal, limpa backdrops órfãos, dispara `toastr` quando disponível. |
| Particularidade central | É single-select e depende de um único conjunto de scripts por página: apenas a **primeira** inclusão emite o `<script>` e ele fica preso ao seu `pickerId`. |
| Como se integra | Namespace `v2-service-gallery`; modal `#gallery-picker-modal-<pickerId>`; evento `lotep:open-gallery-picker-<pickerId>`; callback de 5 argumentos. |
| Se removido, quebra | O layout `v2-cms::layouts.app` inclui o picker global; sem ele (ou sem as rotas `v2.gallery.*`), `route()` falha ao renderizar `scripts.blade.php` e **todas as páginas do CMS** param. |
| Superfície pública | 1 componente raiz, 3 segmentos, 1 folha de estilos, 1 orquestrador de scripts + 5 módulos JS, 1 evento de abertura, 1 callback. |

## 1. Efeito

### 1.1 Garantias do componente

- Renderiza um modal Bootstrap 5 (`modal fade`, `modal-lg`) com título
  `Galeria de Mídias V2 - <OWNER EM MAIÚSCULAS>`
  (`V2/Services/Gallery/Views/components/gallery-picker.blade.php:21-29`).
- Garante **uma única** inclusão do CSS do Cropper e do bloco de estilos, por meio de
  `$GLOBALS['gallery_picker_styles_rendered']` (`gallery-picker.blade.php:9-12`).
- Garante **uma única** tag `<script src=".../cropper.min.js">` por página, via
  `$GLOBALS['gallery_picker_cropper_rendered']` (`:14-19`).
- Expõe um `pickerId` estável derivado de `name` (default `global`) que sufixa **todos** os IDs
  (`gallery-picker.blade.php:2-7`).
- Entrega os 4 segmentos na ordem: upload/recorte, metadados, filtros, grade; o painel de detalhe
  começa oculto (`logic/detail-segment.blade.php:2`).
- Sem JS, nada abre: não há `data-bs-toggle`, e os botões só têm handlers jQuery.

### 1.2 O que muda por instância

| Variável | Origem | Default | Efeito |
|---|---|---|---|
| `$pickerId` | `$name` | `global` | Sufixo de todos os IDs/DOM e nome do evento |
| `$owner` | `$ownerModule` | `global` | Título e `owner_module` enviado nas requisições (pode ser sobrescrito pelo evento) |
| `$mimeTypes` | `implode(',', $allowedMimeTypes)` | `image/*,application/pdf` | `accept` do input, ajuda, e filtro de listagem |
| `$shared` | `(bool) $allowShared` | `true` | `allow_shared` nas requisições e leitura de mídias compartilhadas |

Tudo o mais é global ao componente. O título é renderizado no servidor e **não** muda quando o evento
sobrescreve `ownerModule` (`logic/scripts/main-orchestrator.blade.php:66-71` altera só a variável JS).

### 1.3 O que o componente NÃO faz

- Não oferece seleção múltipla: o clique seleciona um card e o callback devolve um único ID
  (`logic/scripts/gallery-loader.blade.php:54-59`). Campos múltiplos usam
  `v2-service-media::components.media-group-input`, que não abre o picker.
- Não cria thumbnail para PDF/áudio/planilha; usa ícones Bootstrap (`gallery-loader.blade.php:27-35`).
- Não exclui permanentemente: a rota `DELETE` existe, mas nenhum handler/JS a chama.
- Não navega por teclado na grade nem anuncia resultados (ver §6).
- Não atualiza o título nem re-renderiza o `accept` do dropzone quando o evento troca o módulo/MIMEs.

- Não é uma tela de listagem administrativa: não há paginação, ordenação por coluna, filtros por campo
  (só busca textual e tipo) nem bulk actions. O limite é o acervo visível retornado de uma vez.
- Não renderiza nada no portal público (H1–H6 não se aplicam): o portal consome `file_path`/`variants`
  das relations dos módulos, não este componente.

## 2. Abordagem

### 2.1 Hierarquia de composição

```mermaid
flowchart TD
  P[gallery-picker.blade.php] --> S[logic.styles]
  P --> U[logic.upload-segment]
  P --> G[logic.gallery-segment]
  P --> D[logic.detail-segment]
  P --> J[logic.scripts]
  U --> MI[v2-service-media::components.media-input<br/>noPicker=true]
  J --> L[scripts.gallery-loader]
  J --> C[scripts.cropper-manager]
  J --> H[scripts.upload-handler]
  J --> DM[scripts.detail-manager]
  J --> O[scripts.main-orchestrator]
  L --> API1[GET v2.gallery.index]
  H --> API2[POST v2.gallery.store]
  DM --> API3[PUT/rotate/archive]
```

### 2.2 Ordem de carregamento

1. `styles.blade.php` é `@include` imediato no corpo → `<link>` do Cropper e `<style>` inline
   (`gallery-picker.blade.php:9-12`; `logic/styles.blade.php:1-75`).
2. Cropper JS é `@push('scripts')` com `defer` (`gallery-picker.blade.php:16-18`). O layout precisa
   renderizar `@stack('scripts')` **depois** de jQuery/Bootstrap (o CMS faz isso em
   `V2/layouts/cms/layouts/app.blade.php:140`).
3. `scripts.blade.php` empilha um `<script>` inline que só executa em `$(document).ready`
   (`logic/scripts.blade.php:4-30`). Scripts `defer` executam antes de `DOMContentLoaded`; logo, se
   jQuery/Bootstrap/Cropper também forem `defer` (ou síncronos), `Cropper` e `bootstrap` já estão
   disponíveis quando o handler roda `[I]`.
4. Dentro do ready, a IIFE declara estado e inclui, em ordem, loader → cropper → upload → detail →
   orchestrator. A ordem importa porque os módulos referenciam `loadGallery`, `selectMedia`,
   `destroyCropper`, `handleDirectDroppedFile`, `performUpload` e `activeCallback` declarados antes/depois
   (`scripts.blade.php:24-28`). `main-orchestrator` é o último porque registra o listener do evento e
   depende de todos os anteriores.

### 2.3 Trechos de código pivô

```blade
{{-- V2/Services/Gallery/Views/components/gallery-picker.blade.php:2-12 — props, defaults e
     guard global: somente a primeira instância injeta estilos. --}}
@php
    $pickerId = isset($name) ? $name : 'global';
    $owner = isset($ownerModule) ? $ownerModule : 'global';
    $mimeTypes = isset($allowedMimeTypes) ? implode(',', $allowedMimeTypes) : 'image/*,application/pdf';
    $shared = isset($allowShared) ? (bool) $allowShared : true;
@endphp

@if (!isset($GLOBALS['gallery_picker_styles_rendered']))
    @php $GLOBALS['gallery_picker_styles_rendered'] = true; @endphp
    @include('v2-service-gallery::components.logic.styles')
@endif
```

**Efeito.** `name` vira chave de todas as IDs. Duas inclusões com o mesmo `name` produzem IDs
duplicados e handlers jQuery que atingem ambos os modais.

```blade
{{-- V2/Services/Gallery/Views/components/gallery-picker.blade.php:21-37 — a estrutura que o JS
     procura: modal + 3 segmentos incluídos no corpo; não há data-bs-toggle. --}}
<div class="modal fade" id="gallery-picker-modal-{{ $pickerId }}" tabindex="-1" aria-labelledby="gallery-picker-title-{{ $pickerId }}" aria-hidden="true">
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-header bg-primary text-white py-3 justify-content-between">
                <h5 class="modal-title fw-bold" id="gallery-picker-title-{{ $pickerId }}">
                    <i class="bi bi-images me-2"></i> Galeria de Mídias V2 - {{ strtoupper($owner) }}
                </h5>
                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Fechar"></button>
            </div>
            <div class="modal-body" style="background-color: #f4f6f9;">
                <div class="gallery-single-layout">
                    @include('v2-service-gallery::components.logic.upload-segment')
                    @include('v2-service-gallery::components.logic.gallery-segment')
                    @include('v2-service-gallery::components.logic.detail-segment')
                </div>
            </div>
```

**Efeito.** O modal só é exibido por `bootstrap.Modal(...).show()` no listener do evento. O botão de
fechar usa `data-bs-dismiss`; `keyboard:false` e `backdrop:false` impedem fechar por ESC/clique fora
(`main-orchestrator.blade.php:42-46`). Trecho truncado após o `@include` de detalhe; os fechamentos de
`modal-body`/`modal-content`/`modal-dialog`/`modal` estão em `:38-43`.

```blade
{{-- V2/Services/Gallery/Views/components/logic/scripts.blade.php:1-20 — guard de scripts e
     estado capturado uma única vez por página. --}}
@if (!isset($GLOBALS['gallery_picker_scripts_rendered']))
    @php $GLOBALS['gallery_picker_scripts_rendered'] = true; @endphp
    @push('scripts')
    <script>
    $(document).ready(function() {
        (() => {
            const pickerId = "{{ $pickerId }}";
            let ownerModule = "{{ $owner }}";
            const allowShared = "{{ $shared ? '1' : '0' }}";
            let filterMimeTypes = "{{ $mimeTypes }}";
            const csrfToken = "{{ csrf_token() }}";
            let activeCallback = null;
            let cropperInstance = null;
            let fileToUpload = null;
            const urls = {
                index: "{{ route('v2.gallery.index') }}",
                store: "{{ route('v2.gallery.store') }}"
            };
```

**Efeito.** A primeira instância renderizada "ganha" a página: o `<script>` declara `pickerId` como
`const`. As demais instâncias têm markup, mas **nenhum** listener/handler. Em páginas com picker de
módulo (ex.: BasicPage) e o picker global do layout, o global fica inerte. Também é aqui que
`route('v2.gallery.index')` é resolvido em tempo de render: sem as rotas, o Blade lança exceção.

```blade
{{-- V2/Services/Gallery/Views/components/logic/scripts/main-orchestrator.blade.php:61-82 (resumido) —
     contrato de abertura: callback obrigatório; overrides de módulo/mimes; arquivo arrastado. --}}
window.addEventListener('lotep:open-gallery-picker-' + pickerId, function(e) {
    if (e.detail && typeof e.detail.callback === 'function') {
        activeCallback = e.detail.callback;
        // Dynamic overrides from event detail (when called from media-input)
        if (e.detail.ownerModule) {
            ownerModule = e.detail.ownerModule;
        }
        if (e.detail.allowedMimeTypes) {
            filterMimeTypes = e.detail.allowedMimeTypes;
        }
        destroyCropper();
        $('#gallery-detail-segment-' + pickerId).hide();
        loadGallery();
        // ... openGalleryModal(pickerId) e detail.droppedFile em :77-81
    }
});
```

**Efeito.** Sem `detail.callback` o evento é ignorado. `ownerModule`/`allowedMimeTypes` passam a valer
para as próximas requisições até outro evento sobrescrever. `loadGallery()` é síncrono no disparo e
assíncrono na resposta; o modal abre antes dos cards.

```blade
{{-- V2/Services/Gallery/Views/components/logic/scripts/upload-handler.blade.php:58-70 —
     payload do upload: o módulo e os mimes vêm do estado JS, não do formulário. --}}
const performUpload = (file) => {
    var altText = $('#upload-media-alt-' + pickerId).val();
    var desc = $('#upload-media-desc-' + pickerId).val();
    var isShared = $('#upload-media-shared-' + pickerId).is(':checked') ? '1' : '0';

    var formData = new FormData();
    formData.append('file', file);
    formData.append('owner_module', ownerModule);
    formData.append('is_shared', isShared);
    formData.append('file_alt', altText);
    formData.append('description', desc);
    formData.append('allowed_mime_types', filterMimeTypes);
    formData.append('_token', csrfToken);
```

**Efeito.** O servidor recebe `owner_module` arbitrário do cliente; o `accept` do dropzone não valida
nada no envio. Trecho truncado antes do `$.ajax` em `:72`. `is_shared` começa marcado (`upload-segment.blade.php:68-71`), então o padrão é
compartilhar.

```blade
{{-- V2/Services/Gallery/Views/components/logic/scripts/upload-handler.blade.php:81-92 — sucesso:
     callback + toastr + reset do input; o modal é fechado por selectMedia. --}}
success: function(anexo) {
    selectMedia(anexo.id, '/storage/' + anexo.file_path, anexo.alt_text, anexo.mime_type || '', anexo.description || '');
    if (typeof toastr !== 'undefined') toastr.success('Upload concluído!');

    /* Reseta o input file para permitir novos uploads */
    var container = document.getElementById('upload-drop-segment-' + pickerId);
    var fileInput = container ? container.querySelector('#media-input-gallery_file_' + pickerId) : null;
    if (fileInput) {
        fileInput.value = '';
    }
    destroyCropper();
}
```

**Efeito.** O upload já devolve a mídia selecionada; o usuário não passa pelo detalhe. Em erro,
`xhr.responseJSON.message` é exibido via `toastr` (se existir) e o crop/arquivo permanece na tela
(`upload-handler.blade.php:93-96`).

```blade
{{-- V2/Services/Gallery/Views/components/logic/scripts/cropper-manager.blade.php:23-39 —
     "Registrar": com Cropper envia o canvas recortado; sem Cropper envia o arquivo cru. --}}
$('#btn-register-upload-' + pickerId).on('click', function() {
    if (cropperInstance) {
        /* Com cropper: recorta e faz upload do recorte */
        cropperInstance.getCroppedCanvas().toBlob(function(blob) {
            var croppedFile = new File([blob], fileToUpload.name, {
                type: fileToUpload.type,
                lastModified: Date.now()
            });
            performUpload(croppedFile);
            destroyCropper();
        }, fileToUpload.type);
    } else if (fileToUpload) {
        /* Sem cropper (áudio, PDF, planilha): upload direto */
        performUpload(fileToUpload);
        destroyCropper();
    }
});
```

**Efeito.** O botão não é desabilitado durante o envio: dois cliques disparam dois uploads. O crop usa
o **mesmo nome e MIME** do original (um PNG continua PNG). `destroyCropper()` limpa alt/desc e esconde
o botão após o disparo.

```blade
{{-- V2/Services/Gallery/Views/components/logic/scripts/detail-manager.blade.php:233-245 —
     "Confirmar Escolha" salva metadados e devolve a mídia mesmo se o PUT falhar. --}}
$.ajax({
    url: urls.index + '/' + id,
    type: 'PUT',
    data: {
        owner_module: ownerModule,
        alt_text: altText,
        description: desc
    },
    complete: function() {
        selectMedia(id, url, altText, mimeType, desc);
    }
});
```

**Efeito.** `complete` (não `success`) faz o callback rodar também em 404/500. Para mídia compartilhada
de outro módulo, o PUT falha (dono diferente) e o alt/desc editado é devolvido ao formulário mas não
persistido — a próxima abertura mostra o valor antigo.

### 2.4 Estado e ciclo de vida

```mermaid
stateDiagram-v2
  [*] --> Fechado
  Fechado --> Grade: evento lotep:open-gallery-picker-<id>
  Grade --> Detalhe: clique em .gallery-item-<id>
  Grade --> RecorteUpload: arquivo escolhido/arrastado
  RecorteUpload --> Grade: Registrar (upload ok) + selectMedia
  RecorteUpload --> RecorteUpload: erro (toastr)
  Detalhe --> CropperDetalhe: Recortar
  CropperDetalhe --> Detalhe: Aplicar Recorte (novo upload + archive do antigo)
  Detalhe --> Grade: Cancelar / Arquivar
  Detalhe --> Fechado: Confirmar Escolha (selectMedia)
  Grade --> Fechado: botão Fechar
```

- `viewArchived` (0/1) alterna listagem e rótulos; zera o painel de detalhe ao trocar
  (`main-orchestrator.blade.php:86-99`).
- Busca (`keyup`) e tipo (`change`) chamam `loadGallery()` sem debounce
  (`main-orchestrator.blade.php:101-102`).
- `selectMedia(id, path, alt, mimeType, description)` executa `activeCallback` e esconde o modal
  (`gallery-loader.blade.php:54-59`).
- Não há destruição do escopo/IIFE: handlers `$(document).on` permanecem até navegação de página.

## 3. Particularidades

### 3.1 Parciais e IDs únicos

Todos os IDs seguem `<base>-<pickerId>`; o JS monta as seleções por concatenação. Catálogo por segmento:

| Segmento | IDs (sufixo `-<pickerId>`) | Papel |
|---|---|---|
| Upload | `upload-drop-segment`, `upload-fields-container`, `gallery-cropper-wrapper`, `gallery-cropper-image`, `btn-cropper-rotate`, `gallery-audio-wrapper/filename`, `gallery-excel-wrapper/filename`, `gallery-document-wrapper/filename`, `btn-register-upload` | Recebe arquivo, mostra cropper/feedback, dispara upload |
| Metadados | `upload-media-alt`, `upload-media-desc`, `upload-media-shared` | `alt_text`, `description`, `is_shared` |
| Filtros | `gallery-search`, `gallery-filter-type`, `btn-toggle-archived`, `gallery-list-title` | Busca, tipo, alternância ativa/arquivada |
| Grade | `gallery-items-container` | Container AJAX |
| Detalhe | `gallery-detail-segment`, `detail-preview-img/pdf/audio/spreadsheet`, `detail-image-actions`, `detail-media-id/url/mime`, `detail-media-alt/desc`, `detail-group-alt`, `detail-operating-feedback/text`, `btn-detail-rotate`, `btn-detail-crop`, `btn-archive-detail`, `btn-download-detail`, `btn-cancel-detail`, `btn-confirm-selection`, `detail-cropper-wrapper/image`, `btn-detail-crop-rotate/cancel/apply` | Edição e ações |
| Media-input interno | `wrapper-gallery_file_<pickerId>`, `media-id-gallery_file_<pickerId>`, `media-input-gallery_file_<pickerId>` | Dropzone do serviço Media com `noPicker=true` |

O input file real é `#media-input-gallery_file_<pickerId>`; o `change` é delegado no documento
(`upload-handler.blade.php:101-105`) e o reset busca o input dentro de `#upload-drop-segment-<pickerId>`.

### 3.2 Invariantes de DOM

| ID | Regra | Efeito da violação | Fonte |
|---|---|---|---|
| UI-GALLERY-01 | O card deve ter `data-id`, `data-url`, `data-is-img`, `data-is-audio`, `data-mime-type` e a classe `gallery-item-<pickerId>` | Clique não abre detalhe ou abre sem URL/MIME | `gallery-loader.blade.php:39` |
| UI-GALLERY-02 | O container da grade é `#gallery-items-container-<pickerId>` | `loadGallery` não encontra destino e o HTML é descartado | `gallery-loader.blade.php:3` |
| UI-GALLERY-03 | O modal é `#gallery-picker-modal-<pickerId>` | `openGalleryModal` retorna `null` sem abrir | `main-orchestrator.blade.php:7-8` |
| UI-GALLERY-04 | O botão de upload existe e começa oculto (`display:none`) | `destroyCropper` falha silenciosamente e o botão fica visível | `upload-segment.blade.php:74` |
| UI-GALLERY-05 | O dropzone interno precisa de `name=gallery_file_<pickerId>` e `noPicker=true` | Sem `noPicker`, o clique reabre o picker em recursão | `upload-segment.blade.php:8-18` |
| UI-GALLERY-06 | `#gallery-detail-segment` começa com `display:none` | Painel aparece vazio na abertura | `detail-segment.blade.php:2` |

### 3.3 Casos-limite

- **Dois pickers na página**: só o primeiro recebe JS (UI-GALLERY, §2.3). O segundo pode até ser aberto
  por `bootstrap.Modal` externo, mas os botões não têm handler.
- **Mesmo `name` duas vezes**: IDs duplicados; `$('#...')` atinge os dois; uploads podem ir para o
  callback errado.
- **`allowShared=false`**: mídias compartilhadas de outros módulos somem da grade, inclusive as usadas
  por campos de outros módulos.
- **`allowedMimeTypes` vazio**: `implode` gera `''`; o servidor cai no default do DTO
  (`image/*`, `application/pdf`) e o dropzone não restringe nada no cliente.
- **Evento sem callback**: ignorado por completo (nem o modal abre).
- **Arquivo não-imagem**: `handleDirectDroppedFile` mostra o painel correspondente e exibe tamanho em
  MB para áudio/PDF e em KB para planilha (`upload-handler.blade.php:45-54`).
- **PDF/áudio/planilha com Cropper carregado**: `cropperInstance` é `null`, então "Registrar" envia o
  arquivo cru.
- **Imagem com crop**: `getCroppedCanvas()` pode devolver canvas vazio para imagens muito grandes
  (>16k px) e `toBlob` falha; não há tratamento `[I]`.
- **401/419 (sessão expirada)**: `xhr.responseJSON` pode não existir; toastr mostra
  `"Erro ao realizar upload."`.
- **Mídia arquivada**: continua acessível por `find`/download; a grade arquivada permite restaurar pelo
  botão de detalhe, que troca o rótulo para "Desarquivar" (`detail-manager.blade.php:68-73`).
- **Áudio no detalhe**: o código lê `$(this).data('is-audio')` dentro do callback AJAX, onde `this` não
  é o card; o valor é `undefined`, então áudio cai no preview de PDF
  (`detail-manager.blade.php:30-32,59-65`).
- **Recorte no detalhe**: cria **nova** mídia JPEG (`cropped_<timestamp>.jpg`), define `is_shared=0`,
  arquiva a antiga e substitui o ID no painel; a antiga continua no banco como arquivada
  (`detail-manager.blade.php:158-212`).
- **Falha no archive após o crop**: o segundo AJAX usa `complete`, então a UI atualiza mesmo se o
  archive falhar; ficam duas mídias ativas (`detail-manager.blade.php:189-193`).

### 3.4 Armadilhas

1. **Não trocar o guard `$GLOBALS` por `@once` sem mudar o escopo do script**: o script declara
   `pickerId` como constante e não é uma função reutilizável. Para múltiplas instâncias, é preciso
   transformar todo o bloco em uma factory parametrizada (`initGalleryPicker(pickerId, ...)`) e registrar
   um listener por ID.
2. **A ordem dos `@include` internos é contrato**: `selectMedia` é usado por upload e detalhe; `loadGallery`
   por orchestrator/detalhe/arquivar; `destroyCropper` por orchestrator/upload.
3. **`route()` em Blade**: qualquer página que renderize `scripts.blade.php` exige as rotas
   `v2.gallery.index/store`; falha de registro derruba a renderização, não só o widget.
4. **IDs com `name` arbitrário**: `name` é interpolado em IDs HTML e em seletores jQuery; use apenas
   `[A-Za-z0-9_-]` (os consumidores usam `picker_bp_sidebar`, `lse_global_media_picker`,
   `global-media-picker`, `media-picker-image-picker`).
5. **`/storage/` hardcoded** no card, no callback de upload e no crop
   (`gallery-loader.blade.php:18`; `upload-handler.blade.php:82`; `detail-manager.blade.php:195`):
   incompatível com S3/MinIO sem proxy; o backend usa `StorageService`.
6. **Cache-buster por `updated_at`**: a grade invalida a imagem com `?t=<timestamp>`
   (`gallery-loader.blade.php:26`), mas rotação atualiza `updated_at` sem regenerar variantes.
7. **`confirm()` nativo** para arquivar/restaurar: bloqueia a thread, não é estilizável nem acessível
   (`detail-manager.blade.php:260`).
8. **Nenhum bloqueio de duplo envio**: desabilite o botão e mostre estado de progresso na
   reimplementação.

## 4. Integrabilidade

### 4.1 Contrato de uso

| Item | Obrigatório | Tipo | Efeito |
|---|---|---|---|
| Layout com `@stack('scripts')` | sim | layout | Sem o stack, nenhum `@push` é renderizado e o modal fica inerte |
| `@include('v2-service-gallery::components.gallery-picker', [...])` | sim | include | Renderiza modal + estilos + scripts |
| `name` | recomendado | string | `pickerId` único; default `global` |
| `ownerModule` | recomendado | string | Escopo de listagem/upload; default `global` |
| `allowedMimeTypes` | opcional | array | `accept` + filtro de listagem; default `['image/*','application/pdf']` |
| `allowShared` | opcional | bool | Default `true` |
| Evento `lotep:open-gallery-picker-<pickerId>` | sim para abrir | CustomEvent | `detail.callback` é obrigatório |
| `detail.callback(id, url, alt, mimeType, description)` | sim | função | Recebe a mídia escolhida; `url` é `/storage/<file_path>` |
| `detail.ownerModule` / `detail.allowedMimeTypes` | opcional | string | Sobrescreve o estado da instância |
| `detail.droppedFile` | opcional | `File` | Abre o modal já no fluxo de upload/recorte |
| jQuery + Bootstrap 5 + Cropper | sim | libs | `$`, `bootstrap.Modal`, `Cropper` |
| `toastr` | não | lib | Feedback de sucesso/erro (silencioso se ausente) |

### 4.2 Eventos

| Evento | Onde | Payload | Quem emite | Quem escuta |
|---|---|---|---|---|
| `lotep:open-gallery-picker-<pickerId>` | `window` | `{callback, ownerModule?, allowedMimeTypes?, droppedFile?}` | `media-input.js` (sempre `global-media-picker`), sidebars BasicPage/SLE, LSE, ContentRichEditor | `main-orchestrator.blade.php:61` |
| `change` em `#media-input-gallery_file-<pickerId>` | documento (delegado) | `Event` nativo | input file do `media-input` | `upload-handler.blade.php:101` |
| `hidden.bs.modal.gallery` | modal | — | Bootstrap | `main-orchestrator.blade.php:49-56` (limpa backdrops) |
| `media-selected.lotep` | wrapper do `media-input` **externo** | `{id,url,alt,mimeType,description}` | `public/assets/js/media-input.js:100-106` | formulários dos módulos (ex.: Notícias, `includes/scripts.blade.php:107-108`) |

O picker em si **não** emite evento: ele chama o callback diretamente. O evento
`media-selected.lotep` pertence ao componente `media-input`, que é o consumidor típico.

### 4.3 Receitas de uso

**R1 — picker global (já no layout do CMS) com callback mínimo**

```js
window.dispatchEvent(new CustomEvent('lotep:open-gallery-picker-global-media-picker', {
  detail: {
    callback: function (id, url, alt, mimeType, description) {
      document.querySelector('#cover_image_id').value = id;
    },
    ownerModule: 'noticias',
    allowedMimeTypes: 'image/*'
  }
}));
```

**R2 — picker de módulo com ID próprio (BasicPage)**

```blade
@include('v2-service-gallery::components.gallery-picker', [
    'name' => 'picker_bp_sidebar',
    'ownerModule' => 'basicpage',
    'allowedMimeTypes' => ['image/*'],
    'allowShared' => true,
])
<script>
document.getElementById('bpSbarGalleryBtn').addEventListener('click', function () {
    window.dispatchEvent(new CustomEvent('lotep:open-gallery-picker-picker_bp_sidebar', {
        detail: { callback: (id, url, alt) => createAssetFromGallery(id, alt) }
    }));
});
</script>
```

**R3 — abrir já com arquivo (drag & drop externo)**

```js
window.dispatchEvent(new CustomEvent('lotep:open-gallery-picker-global-media-picker', {
  detail: {
    droppedFile: file,                 // File vindo de um dropzone externo
    callback: (id, url, alt, mime) => selectMedia(id, url, alt, mime)
  }
}));
```

Resultado: o modal abre, o segmento de upload mostra cropper (imagem) ou ícone (PDF/áudio/planilha) e o
botão "Registrar" aparece.

**R4 — upload direto sem abrir a galeria (media-group-input)**

```js
var fd = new FormData();
fd.append('file', file); fd.append('owner_module', 'noticias'); fd.append('is_shared', '1');
fd.append('_token', token);
$.ajax({ url: '/cms/v2/gallery', type: 'POST', data: fd, processData: false, contentType: false });
```

### 4.4 Anti-uso

1. **Incluir dois pickers esperando que ambos funcionem**: só o primeiro recebe scripts. O `media-input`
   contorna isso porque sempre mira `global-media-picker`; pickers nomeados adicionais quebram se o
   global também estiver na página.
2. **Incluir o picker fora do layout CMS**: os `@push('scripts')` não são renderizados; o modal existe
   no DOM mas nunca abre. Também faltam jQuery/Bootstrap/Cropper.
3. **Usar `name` com espaços/colchetes**: gera IDs inválidos e seletores quebrados; o
   `media-input` externo sanitiza apenas para o evento, não para o `name` do picker.
4. **Esperar seleção múltipla**: o picker é single-select; use `media-group-input` e envie
   `<nome>_ids[]` (o servidor sincroniza via `sync`/`syncGalleryMediaIds`).
5. **Editar `alt_text` de mídia compartilhada**: o PUT falha por dono e o callback devolve o texto não
   persistido; a próxima abertura reverte.

## 5. Catálogo de arquivos

| Arquivo | Papel | Variáveis/props | Observação |
|---|---|---|---|
| `Views/components/gallery-picker.blade.php` | Raiz: props, guards de CSS/Cropper, modal e includes | `name`, `ownerModule`, `allowedMimeTypes`, `allowShared` | Gera `$pickerId`, `$owner`, `$mimeTypes`, `$shared` |
| `Views/components/logic/styles.blade.php` | `<link>` do Cropper + `<style>` do componente | — | Classes `.gallery-single-layout`, `.gallery-segment(-title)`, `.gallery-grid-container`, `.gallery-item-card(.selected)`, `.media-detail-preview-box`, animações `.pulse-animation`, `.bar-anim` |
| `Views/components/logic/upload-segment.blade.php` | Segmentos 1 e 2: dropzone + metadados | `$pickerId`, `$mimeTypes` | Inclui `media-input` com `noPicker=true`; botão Registrar oculto |
| `Views/components/logic/gallery-segment.blade.php` | Segmentos 3 e 4: filtros + container | `$pickerId` | Opções `image, pdf, audio, spreadsheet`; botão de arquivados |
| `Views/components/logic/detail-segment.blade.php` | Painel de detalhe + cropper inline | `$pickerId` | Previews e ações; começa `display:none` |
| `Views/components/logic/scripts.blade.php` | Bootstrap do JS, estado e ordem dos módulos | `$pickerId`, `$owner`, `$shared`, `$mimeTypes` | Guard global; `route()` em render |
| `Views/components/logic/scripts/gallery-loader.blade.php` | `loadGallery` + `selectMedia` | — | HTML dos cards, detecção por MIME/extensão, spinner, vazio |
| `Views/components/logic/scripts/cropper-manager.blade.php` | `destroyCropper` + rotação de upload + Registrar | — | Cria `Cropper(viewMode:1, autoCropArea:1)` |
| `Views/components/logic/scripts/upload-handler.blade.php` | `handleDirectDroppedFile` + `performUpload` | — | Feedback por tipo, FormData, toastr, reset |
| `Views/components/logic/scripts/detail-manager.blade.php` | Clique no card, rotação, crop, confirmar, arquivar | — | Reconsulta a listagem no clique; `confirm()` nativo |
| `Views/components/logic/scripts/main-orchestrator.blade.php` | Abertura do modal, listener do evento, filtros/arquivados | — | `backdrop:false`, `keyboard:false`; limpeza de backdrops |

## 6. Acessibilidade e responsividade

**O que existe**

- Modal com `aria-labelledby` apontando para o título e `aria-hidden="true"` inicial; botão de fechar
  com `aria-label="Fechar"` (`gallery-picker.blade.php:21-28`).
- Ícones decorativos com `aria-hidden="true"` nos feedbacks de tipo
  (`upload-segment.blade.php:34-52`).
- Dropzone do `media-input` com `role="button"`, `tabindex="0"` e rótulo via `<label>`
  (`V2/Services/Media/Views/components/media-input.blade.php:35-45`).
- Inputs de metadados têm `<label for>` (`upload-segment.blade.php:61-71`; `detail-segment.blade.php:41-47`).
- Cards usam `title` com `alt_text` (`gallery-loader.blade.php:39`).

**O que falta / riscos**

| ID | Problema | Efeito | Fonte |
|---|---|---|---|
| A11Y-GALLERY-01 | Cards da grade são `<div>` sem `role`, `tabindex` ou suporte a Enter/Espaço | Teclado não seleciona mídia; leitor de tela não anuncia item acionável | `gallery-loader.blade.php:37-47` |
| A11Y-GALLERY-02 | Thumbnails `<img>` sem `alt` (o texto do card é visual) | Imagem sem nome acessível | `gallery-loader.blade.php:26` |
| A11Y-GALLERY-03 | Campo de busca depende de `placeholder`, sem `<label>` | Nome acessível frágil | `gallery-segment.blade.php:6` |
| A11Y-GALLERY-04 | Feedback de operação (`detail-operating-feedback`) não tem `role="status"`/`aria-live` | Leitores não anunciam "Processando/Aplicando recorte" | `detail-segment.blade.php:51-53` |
| A11Y-GALLERY-05 | Modal com `keyboard:false` e `backdrop:false` | ESC não fecha; foco pode permanecer no fundo | `main-orchestrator.blade.php:42-46` |
| A11Y-GALLERY-06 | `confirm()` nativo para arquivar/restaurar | Diálogo fora do padrão visual e sem contexto de a11y consistente | `detail-manager.blade.php:260` |
| A11Y-GALLERY-07 | Mensagens de sucesso/erro via `toastr` sem região viva garantida | Leitores podem não anunciar | `upload-handler.blade.php:83,95` |
| A11Y-GALLERY-08 | Cropper é operado por mouse; não há instruções alternativas | Recorte inacessível por teclado | `cropper-manager.blade.php:33-37` |

**Responsividade.** Layout em `flex-direction: column` com `gap: 15px`
(`styles.blade.php:4-8`); upload e metadados usam `col-md-6 col-12`; a grade usa
`col-6 col-sm-4 col-md-3` (`gallery-loader.blade.php:38`); o container rola em `max-height: 300px`
(`styles.blade.php:26-34`). O modal é `modal-lg`, sem variante fullscreen. Não há `prefers-reduced-motion`
para as animações `.pulse-animation`/`.bar-anim`.

## 7. Assets e build

| Asset | Origem | Carregamento | Observação |
|---|---|---|---|
| `assets/vendor/cropperjs/cropper.min.css` | vendor versionado em `public/` | `<link>` inline no corpo | Injetado uma vez por `$GLOBALS` |
| `assets/vendor/cropperjs/cropper.min.js` | idem | `@push('scripts')` com `defer` | Uma vez por `$GLOBALS` |
| CSS do componente | `<style>` inline | imediato | Sem build/nonce próprio |
| Bootstrap 5 (modal/utilities) | layout CMS | global | `bootstrap.Modal` obrigatório |
| jQuery | layout CMS | global | Todo o JS do picker usa `$` |
| Bootstrap Icons (`bi bi-*`) | layout CMS | global | Ícones de tipo/ações |
| `toastr` | opcional | global | Verificado com `typeof` |
| `v2-service-media::components.media-input` | serviço Media | include Blade | Traz `assets/js/media-input.js` (uma vez via `$GLOBALS`) |

Não há bundle próprio, hash de cache ou nonce nos assets do componente. O `<script>` inline não recebe
`nonce`; depende da política CSP global (`CspMiddleware`) permitir inline ou o navegador bloqueia a
abertura do picker.

## 8. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-GALLERY-UI-01 | Não há evidência de uso de mais de um picker **funcional** na mesma página; o guard global indica que o caso nunca foi suportado. | Reimplementação precisa de factory por instância. | `scripts.blade.php:1-2`; consumidores com `name` próprio |
| LG-GALLERY-UI-02 | Não foi possível verificar a ordem real de `@push` em todas as telas (depende de a sidebar ser renderizada antes do layout). | O picker vencedor varia por página. | `V2/layouts/cms/layouts/app.blade.php:139-142`; sidebars BasicPage/SLE |
| LG-GALLERY-UI-03 | Não há teste de acessibilidade/teclado no repositório. | Correções de a11y precisam de validação manual. | ausência de testes de UI |
| LG-GALLERY-UI-04 | O comportamento de `getCroppedCanvas().toBlob()` em imagens acima do limite do canvas não é tratado; não há fallback. | Upload pode falhar silenciosamente em fotos muito grandes. | `cropper-manager.blade.php:26-33` |

## 9. Checklist de paridade

- [ ] `PC-GALLERY-UI-01` — Incluir o componente com `name=x` gera `#gallery-picker-modal-x` e todos os IDs sufixados por `-x`; o título mostra `ownerModule` em maiúsculas. Fonte: `gallery-picker.blade.php:2-29`.
- [ ] `PC-GALLERY-UI-02` — Disparar `lotep:open-gallery-picker-x` com `detail.callback` abre o modal, mostra spinner e preenche a grade; sem callback, nada acontece. Fonte: `main-orchestrator.blade.php:61-83`; `gallery-loader.blade.php:2-15`.
- [ ] `PC-GALLERY-UI-03` — Escolher um card, editar alt/desc e "Confirmar Escolha" chama o callback com `(id, '/storage/<path>', alt, mime, desc)` e fecha o modal mesmo se o PUT falhar. Fonte: `detail-manager.blade.php:223-246`; `gallery-loader.blade.php:54-59`.
- [ ] `PC-GALLERY-UI-04` — Escolher arquivo de imagem mostra cropper e "Registrar"; registrar envia o canvas como novo arquivo, executa o callback e limpa alt/desc. Fonte: `upload-handler.blade.php:19-39`; `cropper-manager.blade.php:23-39`.
- [ ] `PC-GALLERY-UI-05` — Escolher PDF/áudio/planilha esconde o dropzone, mostra o painel correto e envia o arquivo cru. Fonte: `upload-handler.blade.php:40-55`.
- [ ] `PC-GALLERY-UI-06` — Alternar "Ver Arquivados" troca título, cor do botão, rótulo Arquivar/Desarquivar e recarrega a grade com `archived=1`. Fonte: `main-orchestrator.blade.php:86-99`; `detail-manager.blade.php:68-73`.
- [ ] `PC-GALLERY-UI-07` — Busca e filtro de tipo disparam `GET v2.gallery.index` com `owner_module`, `allow_shared`, `allowed_mime_types`, `search`, `type`, `archived`. Fonte: `gallery-loader.blade.php:6-15`.
- [ ] `PC-GALLERY-UI-08` — "Aplicar Recorte" no detalhe cria nova mídia JPEG (`is_shared=0`), arquiva a antiga, atualiza preview/download e recarrega a grade. Fonte: `detail-manager.blade.php:150-220`.
- [ ] `PC-GALLERY-UI-09` — Incluir o picker duas vezes na mesma página emite **um** `<script>` e somente o primeiro `pickerId` responde ao evento (comportamento legado a ser corrigido ou reproduzido conscientemente). Fonte: `scripts.blade.php:1-2`.
- [ ] `PC-GALLERY-UI-10` — Sem as rotas `v2.gallery.*`, a renderização da página que inclui o picker falha em `route()`; a reimplementação deve manter as rotas registradas antes do layout ou trocar por URL literal. Fonte: `scripts.blade.php:17-20`.
- [ ] `PC-GALLERY-UI-11` — Cards não são focáveis por teclado no legado; a reimplementação deve corrigir (§6) sem alterar o payload do callback. Fonte: `gallery-loader.blade.php:37-47`.

## 10. Evidências consultadas

- `V2/Services/Gallery/Views/components/gallery-picker.blade.php:1-45`
- `V2/Services/Gallery/Views/components/logic/upload-segment.blade.php:1-80`
- `V2/Services/Gallery/Views/components/logic/gallery-segment.blade.php:1-31`
- `V2/Services/Gallery/Views/components/logic/detail-segment.blade.php:1-92`
- `V2/Services/Gallery/Views/components/logic/scripts.blade.php:1-33`
- `V2/Services/Gallery/Views/components/logic/styles.blade.php:1-75`
- `V2/Services/Gallery/Views/components/logic/scripts/gallery-loader.blade.php:1-60`
- `V2/Services/Gallery/Views/components/logic/scripts/cropper-manager.blade.php:1-39`
- `V2/Services/Gallery/Views/components/logic/scripts/upload-handler.blade.php:1-106`
- `V2/Services/Gallery/Views/components/logic/scripts/detail-manager.blade.php:1-286`
- `V2/Services/Gallery/Views/components/logic/scripts/main-orchestrator.blade.php:1-102`
- `V2/Services/Media/Views/components/media-input.blade.php:1-110`
- `V2/Services/Media/Views/components/media-group-input.blade.php:1-60`
- `public/assets/js/media-input.js:1-180`
- `V2/layouts/cms/layouts/app.blade.php:132-145`
- `V2/Modules/Layouts/BasicPage/Views/cms/components/asset-sidebar.blade.php:102-160`
- `V2/Modules/Support/StructuralLandingEditor/Views/cms/components/asset-sidebar.blade.php:118-170`
- `V2/Services/LandingSectionsEditor/Views/components/forms/landing-sections-editor.blade.php:92`
- `V2/Services/ContentRichEditor/Assets/js/modals.js:225-243`
