25 Commits

Author SHA1 Message Date
0403eaf008 Define package 'Site Users' em todos os submódulos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 07:56:23 -03:00
34d0bd9543 Altera type para drupal-custom-module; renomeia roles pesquisador→researcher e posdoutorando→postdoc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 16:14:09 -03:00
ef3fe2ab30 Adiciona composer.json aos sub-módulos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:36:11 -03:00
0a8620894a Adiciona composer.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:25:17 -03:00
d72f41de97 Adiciona sub-módulo site_users_blog e melhora negociador de tema
Sub-módulo site_users_blog:
- Tipo de conteúdo blog_post (título, corpo, imagem, assuntos)
- Vocabulário blog_tags para categorias
- Listagem em /user/{uid}/blog via Views com filtro contextual por autor
- Padrão Pathauto: user/[node:author:uid]/blog/[node:title]
- hook_node_presave: preenche field_site_section com o autor
- hook_node_access: restringe criação às roles configuradas
- hook_preprocess_structural_pages_menu: injeta item "Blog" quando
  usuário tem posts publicados
- Plugin BlogUserHandler: resolve usuário ancestral para rotas de blog
  (post individual e listagem Views)
- Link "Post de blog" no menu "Adicionar" da conta
- Página de configuração de roles permitidas
- Update 10001: adiciona field_site_section a posts existentes

MicrositeThemeNegotiator:
- Injeta path.current para cobrir rotas sem parâmetro 'user' (ex.: Views)
- Qualquer path /user/{uid}/... recebe o tema do microsite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 07:32:10 -03:00
39de6a7493 Melhorias no microsite e sincronização de fotos LDAP
Fotos LDAP:
- Ignora sync quando conta ainda não tem UID (evitava URI compartilhada)
- Filtra fotos abaixo do tamanho mínimo configurável (padrão 10 KB)
- Adiciona campo ldap_min_photo_size nas configurações e schema
- Update 10010: remove fotos placeholder já existentes
- Update 10011: remove mídias com URI ldap_photo_.{ext} sem UID

Bloco de cabeçalho do microsite:
- Exibe departamento abaixo do nome, sem label, com link para a entidade
- Exibe telefone de trabalho (work_phone) no lugar de phone (restrito)

Página de perfil:
- Título fixo "Perfil de @name" via callback profileTitle()
- Exclui rota profile da substituição de título pelo nó homepage

Subpáginas com URL amigável:
- Adiciona MicrositeSubpagePathProcessor (inbound + outbound)
- Inbound: /user/{username}/{subpage} → /user/{uid}/{subpage}
- Outbound: /user/{uid}/{subpage} → /user/{username}/{subpage}
- Busca alias em todos os idiomas para contornar limitação do AliasManager

Tema do microsite em rotas externas:
- MicrositeThemeNegotiator cobre rotas com parâmetro user sob /user/{user}/
- Cobre nós do structural_pages cujo alias começa com /user/{uid}/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:29:40 -03:00
aa24bf79f8 Corrige detecção do tema microsite e adiciona padrão Pathauto para usuários
- MicrositeThemeNegotiator: substitui verificação por regex no alias (que
  quebrava com aliases não-numéricos como /user/brunof) por verificação
  direta do nome da rota
- Adiciona config/optional/pathauto.pattern.user_site_mapping.yml para
  criar automaticamente o padrão de alias user/[user:name] na instalação
  do módulo, quando o Pathauto estiver habilitado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 08:48:19 -03:00
ca9b8e8d53 Adiciona sub-módulo site_users_user_content
Registra o handler de entidade User para o módulo Structural Pages,
permitindo que nós content_page usem um usuário como entidade pai e
viabilizando a hierarquia de páginas do microsite (/user/{id}/...).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:55:35 -03:00
7b747e4eb2 Permite usar título do nó homepage como título da página do microsite
Adiciona checkbox 'use_homepage_title' ao formulário de configuração do
microsite. Quando ativado, o hook_preprocess_block substitui o título do
bloco 'Título da Página' pelo título do nó configurado como homepage.
Sem nó configurado ou no caso de fallback, mantém o nome do usuário.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:32:37 -03:00
a500d9ec09 Adiciona regiões content_above e content_below ao tema do microsite
Regiões posicionadas de forma análoga ao tema Olivero: content_above
dentro do <main> antes do conteúdo principal, e content_below após o
bloco main/sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:32:28 -03:00
18a7aa81cb Estiliza formulário de configuração do microsite
Adiciona biblioteca CSS 'form' ao tema do microsite com estilos para
labels, campos, descrições e botão de submit, seguindo a paleta de cores
do tema. O formulário aplica a classe .microsite-form e anexa a biblioteca
automaticamente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:21:57 -03:00
85bc63b250 Adiciona página de configuração do microsite em /user/{id}/config
Permite ao usuário selecionar qual conteúdo (content_page toplevel) é
exibido na página inicial do microsite. A configuração é armazenada via
user.data e a homepage respeita a escolha com fallback para o primeiro nó
publicado. O nó configurado como homepage é ocultado automaticamente do
menu de navegação (structural_pages). Inclui link "Configuração" no menu
da conta via rota de redirecionamento para o usuário atual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:55:56 -03:00
f4d6c49312 Estende tema do microsite para todas as rotas /user/{id}/...
O negociador de tema passa a verificar o alias do caminho atual em vez
de checar nomes de rota específicos, cobrindo páginas de qualquer módulo
acessadas via alias /user/{id}/.... Adiciona site_users_get_microsite_user()
como helper reutilizável para obter o usuário do microsite a partir da rota
ou do alias, e atualiza MicrositeHeaderBlock e site_tools_share_links para
usá-lo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:17:51 -03:00
c96268e09d Adiciona menu 'Adicionar' configurável no menu da conta do usuário
Item pai 'Adicionar' no menu account com subitens derivados dinamicamente
a partir de site_users.settings:add_content_links. O pai fica oculto quando
o usuário não tem acesso a nenhum dos routes configurados.

- Rota site_users.add_content com _custom_access via AddContentAccessCheck
- hook_menu_links_discovered_alter() gera os subitens com IDs estáveis
- Formulário de settings com tabela editável (label, rota, parâmetro, peso)
- CSS do microsite atualizado com dropdown ao hover/focus-within

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 14:28:21 -03:00
0ce327026d Adiciona atributo title nos links acadêmicos do MicrositeHeaderBlock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:26:11 -03:00
03857ee1f2 Sobrescreve rota canônica do usuário com micro-site e move perfil para /user/{id}/profile
- RouteSubscriber redireciona entity.user.canonical ao MicrositeHomeController
- Nova rota site_users_microsite.profile em /user/{user}/profile com _entity_access
- Corrige configFactory() → config() no MicrositeContentController
- Exclui rota de settings do tema do micro-site no ThemeNegotiator
- Adiciona local task tabs para settings (módulo pai e submódulo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:26:06 -03:00
3257c89ff7 Adiciona campo field_user_homepage e ícone de página pessoal no bloco
- Novo campo link field_user_homepage (página pessoal do usuário)
- Update hook 10009 para instalações existentes
- MicrositeHeaderBlock: variável #homepage via getFieldUri()
- Template: link com ícone 'home' como primeiro item nos links acadêmicos
- CSS: fill para ícone de casinha no fundo escuro do header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:53:13 -03:00
9ec7f951bf Adiciona links acadêmicos ao MicrositeHeaderBlock e formatadores de campo
- Formatadores FieldFormatter para Lattes, ORCID e MathSciNet: geram
  link para o perfil na plataforma a partir do ID armazenado no campo
- MicrositeHeaderBlock: inclui lattes_id, orcid_id e mathscinet_id
- Template: exibe ícones SVG via site_tools_academic_icon() (extensão
  Twig do módulo site_tools) com links acessíveis para cada plataforma
- CSS: estilos para .msite-header-block__academic-links com animação
  de hover nos ícones

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 08:52:28 -03:00
84f4661798 Adiciona bloco MicrositeHeaderBlock e regiões ao tema do microsite
- Novo bloco MicrositeHeaderBlock (site_users_microsite): exibe foto
  circular (220×280px), nome (h1), biografia e contatos (telefone,
  e-mail). Título oculto por padrão; biografia renderizada via
  ->processed com |raw no template.
- Remove UserInfoBlock e seu template (não estava em uso).
- Adiciona regiões Top Bar e Navigation ao tema; menu.html.twig para
  gerar classes .menu__item/.menu__link compatíveis com o CSS.
- CSS: estilos das novas regiões e do bloco de cabeçalho.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 07:12:55 -03:00
505c9fb64a Adiciona regiões Top Bar e Navigation ao tema do microsite
- Top Bar: barra estreita acima do header para menu do usuário,
  fundo escuro com links claros alinhados à direita.
- Navigation: barra horizontal abaixo do header para menu da página,
  estilo baseado no nav-secondary do Olivero (hover com underline animado).
- Inclui menu.html.twig para gerar as classes .menu, .menu__item e
  .menu__link necessárias para o CSS funcionar (stable9 não as adiciona).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:10:36 -03:00
0c7b346530 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>
2026-03-16 10:39:22 -03:00
0b137e8d12 Adiciona barra social lateral ao tema do microsite
Implementa o layout estilo Olivero: em desktop (≥ 1200 px) a região
Social fica fixada à esquerda com conteúdo girado −90°; em mobile exibe
barra horizontal compacta. Usa IntersectionObserver para aplicar
.is-fixed ao scroll.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 10:38:54 -03:00
d169052065 Adiciona sub-módulo site_users_microsite e tema site_users_microsite_theme
Sub-módulo com ThemeNegotiator, controller de listagem de conteúdo por usuário,
formulário de configuração de tipos de conteúdo por papel e serviços registrados.

Tema com regiões header, highlighted, tabs, messages, content, sidebar, social
e footer; template page.html.twig com header padrão gerado a partir dos dados
do usuário (foto, nome, roles) quando a região header estiver vazia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:35:45 -03:00
d3c1282e47 Adiciona campo field_user_mathscinetid ao módulo
Inclui config/install com langcode en, update hook 10008 para sites
já instalados e tradução pt-br do label e descrição do campo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 08:13:32 -03:00
3597de7220 Adiciona campo field_person_id com langcode en e tradução pt-br
Inclui field.storage e field.field para o campo Institutional ID,
com tradução 'Id Institucional' em config/translations/pt-br/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:27:57 -03:00
96 changed files with 4615 additions and 330 deletions

123
README.md
View File

@@ -108,6 +108,109 @@ if ($photo instanceof \Drupal\media\MediaInterface) {
} }
``` ```
## Sub-módulo: Site Users Microsite
O sub-módulo `site_users_microsite` provê micro-sites pessoais para cada usuário,
acessíveis em `/user/{id}`, com tema próprio e sem a navegação principal do site.
### Funcionalidades
- Tema alternativo aplicado automaticamente em todas as rotas `/user/{id}` e `/user/{id}/content`
- Página `/user/{id}/content` listando o conteúdo publicado pelo usuário
- Configuração de quais tipos de conteúdo cada papel pode publicar no micro-site
- Aba "Content" adicionada às páginas de perfil de usuário
### Instalação
O sub-módulo está neste repositório mas é ativado independentemente:
```bash
drush en site_users_microsite
```
O tema `site_users_microsite_theme` também precisa ser implantado e ativado
(ver seção [Deploy do tema](#deploy-do-tema) abaixo).
### Configuração
Acesse as configurações em:
**Administração > Configuração > Módulos Locais > Site Users > User Microsite**
`/admin/config/local-modules/site-users/microsite`
Configure quais tipos de conteúdo cada papel pode publicar no micro-site.
### Layout de blocos
No tema `site_users_microsite_theme`, configure os blocos em
**Estrutura > Layout de blocos**:
| Bloco | Região sugerida |
|---|---|
| Informações do Usuário (ou bloco customizado) | Microsite Header |
| Primary tabs | Tabs |
| Status messages | Messages |
| Main page content | Content |
### Permissões
| Permissão | Descrição |
|---|---|
| `administer site_users_microsite settings` | Configurar tipos de conteúdo por papel |
### Variáveis disponíveis no tema
O módulo injeta as seguintes variáveis em `page.html.twig` quando em rotas do micro-site:
```twig
{{ microsite_user }} {# entidade UserInterface do dono do micro-site #}
{{ microsite_user_name }} {# nome de exibição (string) #}
{{ microsite_user_roles }} {# array de roles (exceto 'authenticated') #}
{{ microsite_user_photo }} {# render array da foto padrão (view mode 'thumbnail') #}
```
---
## Deploy do tema
O tema `site_users_microsite_theme` está versionado neste repositório em
`themes/site_users_microsite_theme/`, mas o Drupal só descobre temas em
`themes/custom/`. É necessário implantá-lo manualmente no servidor.
### Opção 1 — Symlink (recomendado em desenvolvimento)
```bash
ln -s /caminho/para/site_users/themes/site_users_microsite_theme \
/caminho/para/drupal/themes/custom/site_users_microsite_theme
```
### Opção 2 — Cópia (recomendado em produção)
```bash
cp -r themes/site_users_microsite_theme \
/caminho/para/drupal/themes/custom/site_users_microsite_theme
```
Após implantar, ative o tema:
```bash
drush theme:enable site_users_microsite_theme
```
O tema não precisa ser definido como tema padrão do site — o módulo
`site_users_microsite` o aplica automaticamente via ThemeNegotiator apenas
nas rotas de micro-site.
### Personalização do tema
- `base theme` em `site_users_microsite_theme.info.yml` está definido como `stable9`
(tema base mínimo do core, sem estilos próprios). Altere para o tema principal
do site se quiser herdar fontes e variáveis CSS.
- O template `templates/layout/page.html.twig` define o layout completo da página.
- O CSS base está em `css/microsite.css`.
---
## Estrutura do Módulo ## Estrutura do Módulo
``` ```
@@ -127,6 +230,26 @@ site_users/
├── templates/ ├── templates/
│ ├── site-user-info-block.html.twig │ ├── site-user-info-block.html.twig
│ └── user--full.html.twig │ └── user--full.html.twig
├── site_users_microsite/ ← sub-módulo
│ ├── config/install/
│ │ └── site_users_microsite.settings.yml
│ ├── src/
│ │ ├── Controller/MicrositeContentController.php
│ │ ├── Form/MicrositeSettingsForm.php
│ │ └── Theme/MicrositeThemeNegotiator.php
│ ├── site_users_microsite.info.yml
│ ├── site_users_microsite.links.menu.yml
│ ├── site_users_microsite.links.task.yml
│ ├── site_users_microsite.module
│ ├── site_users_microsite.permissions.yml
│ ├── site_users_microsite.routing.yml
│ └── site_users_microsite.services.yml
├── themes/
│ └── site_users_microsite_theme/ ← tema (deploy em themes/custom/)
│ ├── css/microsite.css
│ ├── templates/layout/page.html.twig
│ ├── site_users_microsite_theme.info.yml
│ └── site_users_microsite_theme.libraries.yml
├── site_users.info.yml ├── site_users.info.yml
├── site_users.install ├── site_users.install
├── site_users.libraries.yml ├── site_users.libraries.yml

12
composer.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "imecc/site_users",
"description": "Personalizações de usuários do site IMECC: campos, templates e sub-módulos de microsite, blog e conteúdo de usuário.",
"type": "drupal-custom-module",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
"drupal/core": "^10.3 || ^11",
"imecc/site_tools": "*",
"imecc/structural_pages": "*"
}
}

View File

@@ -0,0 +1,23 @@
langcode: en
status: true
dependencies:
config:
- field.storage.user.field_user_mathscinetid
module:
- user
id: user.user.field_user_mathscinetid
field_name: field_user_mathscinetid
entity_type: user
bundle: user
label: 'MathSciNet ID'
description: 'MathSciNet identifier of the researcher.'
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: integer

View File

@@ -0,0 +1,19 @@
langcode: en
status: true
dependencies:
module:
- user
id: user.field_user_mathscinetid
field_name: field_user_mathscinetid
entity_type: user
type: integer
settings:
unsigned: false
size: normal
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View File

@@ -2,6 +2,7 @@ photos:
max_count: 5 max_count: 5
ldap_attribute: 'jpegPhoto' ldap_attribute: 'jpegPhoto'
ldap_sync_enabled: false ldap_sync_enabled: false
ldap_min_photo_size: 10240
user_editable_fields: user_editable_fields:
field_user_name: true field_user_name: true
field_user_phone: true field_user_phone: true
@@ -9,3 +10,4 @@ user_editable_fields:
field_user_social_links: true field_user_social_links: true
field_user_photos: true field_user_photos: true
role_view_modes: { } role_view_modes: { }
add_content_links: [ ]

View File

@@ -0,0 +1,19 @@
langcode: en
status: true
dependencies:
config:
- field.storage.user.field_person_id
module:
- user
id: user.user.field_person_id
field_name: field_person_id
entity_type: user
bundle: user
label: 'Institutional ID'
description: ''
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings: { }
field_type: string

View File

@@ -0,0 +1,22 @@
langcode: en
status: true
dependencies:
config:
- field.storage.user.field_user_homepage
module:
- link
- user
id: user.user.field_user_homepage
field_name: field_user_homepage
entity_type: user
bundle: user
label: 'Personal homepage'
description: 'URL of the user personal homepage.'
required: false
translatable: false
default_value: { }
default_value_callback: ''
settings:
title: 0
link_type: 16
field_type: link

View File

@@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- user
id: user.field_person_id
field_name: field_person_id
entity_type: user
type: string
settings:
max_length: 255
case_sensitive: false
is_ascii: false
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View File

@@ -0,0 +1,20 @@
langcode: en
status: true
dependencies:
module:
- link
- user
id: user.field_user_homepage
field_name: field_user_homepage
entity_type: user
type: link
settings:
title: 0
link_type: 16
module: link
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false

View File

@@ -0,0 +1,14 @@
langcode: pt-br
status: true
dependencies:
module:
- pathauto
- user
id: user_site_mapping
label: 'User site mapping'
type: 'canonical_entities:user'
pattern: 'user/[user:name]'
selection_criteria: { }
selection_logic: and
weight: 0
relationships: { }

View File

@@ -1,13 +0,0 @@
langcode: pt-br
status: true
dependencies:
config:
- user.role.posdoutorando
module:
- user
id: user_add_role_action.posdoutorando
label: 'Add the Posdoutorando role to the selected user(s)'
type: user
plugin: user_add_role_action
configuration:
rid: posdoutorando

View File

@@ -0,0 +1,13 @@
langcode: pt-br
status: true
dependencies:
config:
- user.role.postdoc
module:
- user
id: user_add_role_action.postdoc
label: 'Add the Pós-Doutorando role to the selected user(s)'
type: user
plugin: user_add_role_action
configuration:
rid: postdoc

View File

@@ -2,12 +2,12 @@ langcode: pt-br
status: true status: true
dependencies: dependencies:
config: config:
- user.role.pesquisador - user.role.researcher
module: module:
- user - user
id: user_add_role_action.pesquisador id: user_add_role_action.researcher
label: 'Add the Pesquisador role to the selected user(s)' label: 'Add the Pesquisador role to the selected user(s)'
type: user type: user
plugin: user_add_role_action plugin: user_add_role_action
configuration: configuration:
rid: pesquisador rid: researcher

View File

@@ -1,13 +0,0 @@
langcode: pt-br
status: true
dependencies:
config:
- user.role.posdoutorando
module:
- user
id: user_remove_role_action.posdoutorando
label: 'Remover o papel Posdoutorando dos usuários selecionados'
type: user
plugin: user_remove_role_action
configuration:
rid: posdoutorando

View File

@@ -0,0 +1,13 @@
langcode: pt-br
status: true
dependencies:
config:
- user.role.postdoc
module:
- user
id: user_remove_role_action.postdoc
label: 'Remover o papel Pós-Doutorando dos usuários selecionados'
type: user
plugin: user_remove_role_action
configuration:
rid: postdoc

View File

@@ -2,12 +2,12 @@ langcode: pt-br
status: true status: true
dependencies: dependencies:
config: config:
- user.role.pesquisador - user.role.researcher
module: module:
- user - user
id: user_remove_role_action.pesquisador id: user_remove_role_action.researcher
label: 'Remover o papel Pesquisador dos usuários selecionados' label: 'Remover o papel Pesquisador dos usuários selecionados'
type: user type: user
plugin: user_remove_role_action plugin: user_remove_role_action
configuration: configuration:
rid: pesquisador rid: researcher

View File

@@ -1,8 +1,8 @@
langcode: pt-br langcode: pt-br
status: true status: true
dependencies: { } dependencies: { }
id: posdoutorando id: postdoc
label: Posdoutorando label: Pós-Doutorando
weight: 7 weight: 7
is_admin: false is_admin: false
permissions: { } permissions: { }

View File

@@ -1,7 +1,7 @@
langcode: pt-br langcode: pt-br
status: true status: true
dependencies: { } dependencies: { }
id: pesquisador id: researcher
label: Pesquisador label: Pesquisador
weight: 8 weight: 8
is_admin: false is_admin: false

View File

@@ -15,6 +15,9 @@ site_users.settings:
ldap_sync_enabled: ldap_sync_enabled:
type: boolean type: boolean
label: 'Enable LDAP photo synchronization' label: 'Enable LDAP photo synchronization'
ldap_min_photo_size:
type: integer
label: 'Minimum LDAP photo size in bytes'
user_editable_fields: user_editable_fields:
type: sequence type: sequence
label: 'User-editable profile fields' label: 'User-editable profile fields'
@@ -30,3 +33,25 @@ site_users.settings:
sequence: sequence:
type: string type: string
label: 'View mode machine name' label: 'View mode machine name'
add_content_links:
type: sequence
label: 'Add content menu items'
sequence:
type: mapping
label: 'Add content menu item'
mapping:
label:
type: label
label: 'Menu item label'
route_name:
type: string
label: 'Route name'
route_parameters:
type: sequence
label: 'Route parameters'
sequence:
type: string
label: 'Parameter value'
weight:
type: integer
label: 'Weight'

View File

@@ -0,0 +1 @@
label: 'Id Institucional'

View File

@@ -0,0 +1,2 @@
label: 'Página pessoal'
description: 'Endereço web da página pessoal do usuário.'

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

@@ -0,0 +1,12 @@
{
"name": "imecc/site_users_blog",
"description": "Blog por usuário integrado ao microsite pessoal.",
"type": "drupal-module",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
"drupal/core": "^11",
"imecc/site_users": "*",
"imecc/structural_pages": "*"
}
}

View File

@@ -0,0 +1,63 @@
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.blog_post.field_blog_image
- field.field.node.blog_post.field_blog_tags
- node.type.blog_post
module:
- image
- taxonomy
targetEntityType: node
bundle: blog_post
mode: default
content:
title:
type: string_textfield
weight: -5
region: content
settings:
size: 60
placeholder: ''
third_party_settings: {}
field_blog_image:
type: image_image
weight: 1
region: content
settings:
progress_indicator: throbber
preview_image_style: thumbnail
third_party_settings: {}
body:
type: text_textarea_with_summary
weight: 2
region: content
settings:
rows: 20
summary_rows: 5
placeholder: ''
show_summary: false
third_party_settings: {}
field_blog_tags:
type: entity_reference_autocomplete_tags
weight: 3
region: content
settings:
match_operator: CONTAINS
match_limit: 10
size: 60
placeholder: ''
third_party_settings: {}
status:
type: boolean_checkbox
weight: 10
region: content
settings:
display_label: true
third_party_settings: {}
hidden:
created: true
field_site_section: true
promote: true
sticky: true
uid: true

View File

@@ -0,0 +1,48 @@
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.blog_post.field_blog_image
- field.field.node.blog_post.field_blog_tags
- node.type.blog_post
module:
- image
- taxonomy
- user
targetEntityType: node
bundle: blog_post
mode: default
content:
field_blog_image:
type: image
label: hidden
weight: 0
region: content
settings:
image_style: large
image_link: ''
image_loading:
attribute: lazy
third_party_settings: {}
body:
type: text_default
label: hidden
weight: 1
region: content
settings: {}
third_party_settings: {}
field_blog_tags:
type: entity_reference_label
label: above
weight: 2
region: content
settings:
link: true
third_party_settings: {}
links:
weight: 100
region: content
settings: {}
third_party_settings: {}
hidden:
field_site_section: true

View File

@@ -0,0 +1,49 @@
langcode: pt-br
status: true
dependencies:
config:
- field.field.node.blog_post.field_blog_image
- field.field.node.blog_post.field_blog_tags
- node.type.blog_post
module:
- image
- taxonomy
- user
targetEntityType: node
bundle: blog_post
mode: teaser
content:
field_blog_image:
type: image
label: hidden
weight: 0
region: content
settings:
image_style: medium
image_link: content
image_loading:
attribute: lazy
third_party_settings: {}
body:
type: text_summary_or_trimmed
label: hidden
weight: 1
region: content
settings:
trim_length: 300
third_party_settings: {}
field_blog_tags:
type: entity_reference_label
label: hidden
weight: 2
region: content
settings:
link: true
third_party_settings: {}
links:
weight: 100
region: content
settings: {}
third_party_settings: {}
hidden:
field_site_section: true

View File

@@ -0,0 +1,34 @@
langcode: pt-br
status: true
dependencies:
config:
- field.storage.node.field_blog_image
- node.type.blog_post
entity_type: node
field_name: field_blog_image
bundle: blog_post
label: 'Imagem de destaque'
description: ''
required: false
translatable: true
default_value: {}
default_value_callback: ''
settings:
file_directory: 'blog/[date:custom:Y-m]'
file_extensions: 'png gif jpg jpeg webp'
max_filesize: ''
max_resolution: ''
min_resolution: ''
alt_field: true
alt_field_required: false
title_field: false
title_field_required: false
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
handler: default:file
handler_settings: {}
field_type: image

View File

@@ -0,0 +1,27 @@
langcode: pt-br
status: true
dependencies:
config:
- field.storage.node.field_blog_tags
- node.type.blog_post
- taxonomy.vocabulary.blog_tags
entity_type: node
field_name: field_blog_tags
bundle: blog_post
label: Assuntos
description: ''
required: false
translatable: false
default_value: {}
default_value_callback: ''
settings:
handler: default:taxonomy_term
handler_settings:
target_bundles:
blog_tags: blog_tags
sort:
field: name
direction: asc
auto_create: true
auto_create_bundle: blog_tags
field_type: entity_reference

View File

@@ -0,0 +1,21 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_site_section
- node.type.blog_post
module:
- dynamic_entity_reference
entity_type: node
field_name: field_site_section
bundle: blog_post
label: 'Seção do site'
description: ''
required: false
translatable: false
default_value: {}
default_value_callback: ''
settings:
handler: default
handler_settings: {}
field_type: dynamic_entity_reference

View File

@@ -0,0 +1,37 @@
langcode: en
status: true
dependencies:
module:
- image
entity_type: node
field_name: field_blog_image
type: image
settings:
uri_scheme: public
default_image:
uuid: null
alt: ''
title: ''
width: null
height: null
column_groups:
file:
label: File
columns:
- target_id
- width
- height
translatable: false
alt:
label: Alt
translatable: true
title:
label: Title
translatable: true
module: image
locked: false
cardinality: 1
translatable: true
indexes: {}
persist_with_no_fields: false
custom_storage: false

View File

@@ -0,0 +1,17 @@
langcode: en
status: true
dependencies:
module:
- taxonomy
entity_type: node
field_name: field_blog_tags
type: entity_reference
settings:
target_type: taxonomy_term
module: core
locked: false
cardinality: -1
translatable: true
indexes: {}
persist_with_no_fields: false
custom_storage: false

View File

@@ -0,0 +1,10 @@
langcode: pt-br
status: true
dependencies: {}
name: 'Post de blog'
type: blog_post
description: 'Publicação em um blog de usuário.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: true

View File

@@ -0,0 +1 @@
allowed_roles: {}

View File

@@ -0,0 +1,8 @@
langcode: pt-br
status: true
dependencies: {}
name: 'Assuntos (blog)'
vid: blog_tags
description: 'Categorias e assuntos para posts de blog.'
hierarchy: 0
weight: 0

View File

@@ -0,0 +1,235 @@
langcode: pt-br
status: true
dependencies:
config:
- node.type.blog_post
module:
- node
- user
id: user_blog
label: 'Blog do usuário'
module: views
description: 'Lista posts do blog de um usuário, acessível em /user/{uid}/blog.'
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
id: default
display_title: Default
display_plugin: default
position: 0
display_options:
title: Blog
pager:
type: full
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
tags:
previous: ' Anterior'
next: 'Próximo '
first: '« Primeira'
last: 'Última »'
expose:
items_per_page: false
items_per_page_label: 'Itens por página'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- Todos -'
offset: false
offset_label: Deslocamento
quantity: 9
style:
type: default
options:
grouping: []
row_class: ''
default_row_class: true
row:
type: 'entity:node'
options:
relationship: none
view_mode: teaser
fields: {}
filters:
status:
id: status
table: node_field_data
field: status
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: status
plugin_id: boolean
operator: '='
value: '1'
group: 1
exposed: false
expose:
operator: ''
is_grouped: false
type:
id: type
table: node_field_data
field: type
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: type
plugin_id: bundle
operator: in
value:
blog_post: blog_post
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
operator_limit_selection: false
operator_list: {}
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: {}
group_items: {}
sorts:
created:
id: created
table: node_field_data
field: created
relationship: none
group_type: group
admin_label: ''
entity_type: node
entity_field: created
plugin_id: date
order: DESC
expose:
label: ''
granularity: second
arguments:
uid:
id: uid
table: node_field_data
field: uid
relationship: none
group_type: group
admin_label: Usuário
entity_type: node
entity_field: uid
plugin_id: node_uid
default_action: 'not found'
exception:
value: all
title_enable: false
title: All
title_enable: false
title: ''
default_argument_type: raw
default_argument_options:
index: 1
use_alias: false
summary_options:
base_path: ''
count: true
items_per_page: 25
override: false
summary:
sort_order: asc
number_of_records: 0
format: default_summary
specify_validation: true
validate:
type: 'entity:user'
fail: 'not found'
validate_options:
access: false
operation: view
multiple: '0'
roles: {}
break_phrase: false
not: false
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: {}
empty:
area_text_custom:
id: area_text_custom
table: views
field: area_text_custom
relationship: none
group_type: group
admin_label: ''
plugin_id: text_custom
content: 'Nenhum post publicado ainda.'
tokenize: false
empty: true
header: {}
footer: {}
relationships: {}
use_ajax: false
query:
type: views_query
options:
query_comment: ''
disable_sql_rewrite: false
distinct: false
replica: false
query_tags: []
exposed_form:
type: basic
options:
submit_button: Aplicar
reset_button: false
reset_button_label: Resetar
exposed_sorts_label: 'Ordenar por'
expose_sort_order: true
sort_asc_label: Crescente
sort_desc_label: Decrescente
use_more: false
use_more_always: true
use_more_text: mais
display_extenders: {}
rendering_language: '***LANGUAGE_language_interface***'
page_user_blog:
id: page_user_blog
display_title: 'Página do blog'
display_plugin: page
position: 1
display_options:
display_extenders: {}
path: 'user/%/blog'
menu:
type: none
title: ''
description: ''
expanded: false
parent: ''
weight: 0
context: '0'
menu_name: main

View File

@@ -0,0 +1,20 @@
langcode: pt-br
status: true
dependencies:
module:
- node
- pathauto
- user
id: blog_post
label: 'Blog post'
type: 'canonical_entities:node'
pattern: 'user/[node:author:uid]/blog/[node:title]'
selection_criteria:
bundle:
id: 'entity_bundle:node'
negate: false
bundles:
blog_post: blog_post
selection_logic: and
weight: 0
relationships: {}

View File

@@ -0,0 +1,9 @@
site_users_blog.settings:
type: config_object
label: 'Configurações do blog de usuário'
mapping:
allowed_roles:
type: sequence
label: 'Roles com permissão para criar posts'
sequence:
type: boolean

View File

@@ -0,0 +1,12 @@
name: 'Site Users — Blog'
type: module
description: 'Blog por usuário integrado ao microsite pessoal.'
package: 'Site Users'
core_version_requirement: ^11
dependencies:
- drupal:node
- drupal:taxonomy
- drupal:views
- drupal:user
- site_users:site_users
- structural_pages:structural_pages

View File

@@ -0,0 +1,79 @@
<?php
/**
* @file
* Instalação do módulo site_users_blog.
*/
/**
* Implements hook_install().
*/
function site_users_blog_install(): void {
// Adiciona o campo body ao tipo blog_post.
$type = \Drupal::entityTypeManager()
->getStorage('node_type')
->load('blog_post');
if ($type) {
node_add_body_field($type, 'Conteúdo');
}
}
/**
* Adiciona field_site_section ao blog_post e preenche posts existentes.
*/
function site_users_blog_update_10001(): string {
$entity_type_manager = \Drupal::entityTypeManager();
// Cria a instância do campo se ainda não existir.
if (!\Drupal\field\Entity\FieldConfig::loadByName('node', 'blog_post', 'field_site_section')) {
\Drupal\field\Entity\FieldConfig::create([
'field_name' => 'field_site_section',
'entity_type' => 'node',
'bundle' => 'blog_post',
'label' => 'Seção do site',
'required' => FALSE,
'translatable' => FALSE,
])->save();
// Oculta o campo no formulário padrão.
$form_display = $entity_type_manager
->getStorage('entity_form_display')
->load('node.blog_post.default');
if ($form_display) {
$form_display->removeComponent('field_site_section')->save();
}
// Oculta o campo nas exibições padrão e teaser.
foreach (['default', 'teaser'] as $mode) {
$view_display = $entity_type_manager
->getStorage('entity_view_display')
->load('node.blog_post.' . $mode);
if ($view_display) {
$view_display->removeComponent('field_site_section')->save();
}
}
}
// Preenche posts existentes que ainda não têm field_site_section.
$nids = $entity_type_manager->getStorage('node')->getQuery()
->condition('type', 'blog_post')
->notExists('field_site_section')
->accessCheck(FALSE)
->execute();
$count = 0;
foreach ($nids as $nid) {
$node = $entity_type_manager->getStorage('node')->load($nid);
if ($node) {
$node->set('field_site_section', [
'target_type' => 'user',
'target_id' => $node->getOwnerId(),
]);
$node->save();
$count++;
}
}
return "field_site_section adicionado ao blog_post; $count posts existentes atualizados.";
}

View File

@@ -0,0 +1,15 @@
site_users_blog.add_blog_post:
title: 'Post de blog'
route_name: node.add
route_parameters:
node_type: blog_post
menu_name: account
parent: site_users.add_content
weight: 10
site_users_blog.settings:
title: 'Blog de usuário'
description: 'Configura roles com permissão para publicar posts de blog.'
route_name: site_users_blog.settings
parent: site_users_microsite.settings
weight: 10

View File

@@ -0,0 +1,102 @@
<?php
/**
* @file
* Blog pessoal por usuário.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeInterface;
use Drupal\user\UserInterface;
/**
* Implements hook_node_presave().
*
* Preenche field_site_section com o autor do post para que o bloco de
* navegação do structural_pages encontre o usuário ancestral corretamente.
*/
function site_users_blog_node_presave(NodeInterface $node): void {
if ($node->bundle() !== 'blog_post') {
return;
}
if (!$node->hasField('field_site_section') || !$node->get('field_site_section')->isEmpty()) {
return;
}
$node->set('field_site_section', [
'target_type' => 'user',
'target_id' => $node->getOwnerId(),
]);
}
/**
* Implements hook_node_access().
*
* Restringe a criação de blog_post às roles configuradas pelo administrador.
* Se nenhuma role estiver configurada, qualquer usuário autenticado pode criar.
*/
function site_users_blog_node_access(NodeInterface $node, string $op, AccountInterface $account): AccessResultInterface {
if ($node->bundle() !== 'blog_post' || $op !== 'create') {
return AccessResult::neutral();
}
$allowed = \Drupal::config('site_users_blog.settings')->get('allowed_roles') ?? [];
$allowed = array_filter($allowed);
$result = AccessResult::neutral()
->addCacheTags(['config:site_users_blog.settings'])
->cachePerUser();
if (empty($allowed)) {
return $result;
}
foreach (array_keys($allowed) as $role) {
if ($account->hasRole($role)) {
return $result;
}
}
return AccessResult::forbidden()
->addCacheTags(['config:site_users_blog.settings'])
->cachePerUser();
}
/**
* Implements hook_preprocess_structural_pages_menu().
*
* Adiciona o item "Blog" ao menu do microsite quando o usuário tem pelo menos
* um post publicado.
*/
function site_users_blog_preprocess_structural_pages_menu(array &$variables): void {
$user = site_users_get_microsite_user();
if ($user === NULL) {
return;
}
$nids = \Drupal::entityTypeManager()->getStorage('node')
->getQuery()
->condition('uid', $user->id())
->condition('type', 'blog_post')
->condition('status', 1)
->accessCheck(FALSE)
->range(0, 1)
->execute();
if (empty($nids)) {
return;
}
$url = \Drupal\Core\Url::fromRoute('view.user_blog.page_user_blog', [
'arg_0' => $user->id(),
])->toString();
$variables['tree'][] = [
'id' => 0,
'title' => t('Blog'),
'url' => $url,
'is_redirect' => FALSE,
'children' => [],
];
}

View File

@@ -0,0 +1,3 @@
administer site_users_blog settings:
title: 'Administrar configurações do blog de usuário'
restrict access: true

View File

@@ -0,0 +1,7 @@
site_users_blog.settings:
path: '/admin/config/local-modules/site-users/blog'
defaults:
_form: '\Drupal\site_users_blog\Form\BlogSettingsForm'
_title: 'Blog de usuário'
requirements:
_permission: 'administer site_users_blog settings'

View File

@@ -0,0 +1,63 @@
<?php
namespace Drupal\site_users_blog\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\user\RoleInterface;
/**
* Formulário de configuração do blog de usuário.
*/
class BlogSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames(): array {
return ['site_users_blog.settings'];
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'site_users_blog_settings_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('site_users_blog.settings');
$allowed = $config->get('allowed_roles') ?? [];
$roles = user_roles(TRUE);
unset($roles[RoleInterface::AUTHENTICATED_ID]);
$options = array_map(fn($role) => $role->label(), $roles);
$form['allowed_roles'] = [
'#type' => 'checkboxes',
'#title' => $this->t('Roles que podem criar posts de blog'),
'#description' => $this->t('Nenhuma seleção significa que qualquer usuário autenticado pode criar posts.'),
'#options' => $options,
'#default_value' => array_keys(array_filter($allowed)),
];
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$selected = array_filter($form_state->getValue('allowed_roles'));
$this->config('site_users_blog.settings')
->set('allowed_roles', $selected)
->save();
parent::submitForm($form, $form_state);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Drupal\site_users_blog\Plugin\ParentEntityHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\node\NodeInterface;
use Drupal\structural_pages\Attribute\ParentEntityHandler;
use Drupal\structural_pages\ParentEntityHandler\ParentEntityHandlerBase;
use Drupal\user\UserInterface;
/**
* Determina o usuário pai a partir de rotas de blog (post ou listagem).
*
* Cobre:
* - entity.node.canonical de blog_post → retorna o autor
* - view.user_blog.page_user_blog → retorna o usuário cujo UID é arg_0
*/
#[ParentEntityHandler(
id: 'blog_user',
label: new TranslatableMarkup('Blog do usuário'),
entity_type_id: 'user',
clears_site_section: FALSE,
sort_field: 'name',
weight: 20,
)]
class BlogUserHandler extends ParentEntityHandlerBase {
/**
* {@inheritdoc}
*/
public function getEntityFromRoute(RouteMatchInterface $route_match): ?EntityInterface {
$route_name = $route_match->getRouteName() ?? '';
if ($route_name === 'entity.node.canonical') {
$node = $route_match->getParameter('node');
if ($node instanceof NodeInterface && $node->bundle() === 'blog_post') {
return $node->getOwner();
}
return NULL;
}
if ($route_name === 'view.user_blog.page_user_blog') {
$uid = $route_match->getParameter('arg_0');
if (is_numeric($uid)) {
return $this->entityTypeManager->getStorage('user')->load($uid);
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function handlesEntity(EntityInterface $entity): bool {
return $entity instanceof UserInterface;
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "imecc/site_users_microsite",
"description": "Micro-site pessoal para usuários do site, com tema próprio e conteúdo por papel.",
"type": "drupal-module",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
"drupal/core": "^10.3 || ^11",
"imecc/site_users": "*",
"imecc/structural_pages": "*"
}
}

View File

@@ -0,0 +1 @@
role_content_types: {}

View File

@@ -0,0 +1,10 @@
name: 'Site Users Microsite'
type: module
description: 'Micro-site pessoal para usuários do site, com tema próprio e conteúdo por papel.'
core_version_requirement: ^10 || ^11
package: 'Site Users'
dependencies:
- drupal:user
- drupal:node
- site_users:site_users
- structural_pages:structural_pages

View File

@@ -0,0 +1,6 @@
<?php
/**
* @file
* Install, update and uninstall functions for the Site Users Microsite module.
*/

View File

@@ -0,0 +1,12 @@
site_users_microsite.settings:
title: 'User Microsite'
description: 'Configure content types available per role in user micro-sites.'
route_name: site_users_microsite.settings
parent: site_users.settings
weight: 10
site_users_microsite.my_config:
title: 'Configuração'
route_name: site_users_microsite.my_config
menu_name: account
weight: 5

View File

@@ -0,0 +1,20 @@
# Aba de configurações do submódulo na página de settings do módulo pai.
site_users_microsite.settings_tab:
route_name: site_users_microsite.settings
title: 'Microsite'
base_route: site_users.settings
weight: 10
# Aba "Perfil" nas páginas do micro-site do usuário.
site_users_microsite.profile_tab:
route_name: site_users_microsite.profile
title: 'Profile'
base_route: entity.user.canonical
weight: 5
# Aba "Conteúdo" nas páginas do micro-site do usuário.
site_users_microsite.content_tab:
route_name: site_users_microsite.content
title: 'Content'
base_route: entity.user.canonical
weight: 10

View File

@@ -0,0 +1,154 @@
<?php
/**
* @file
* Sub-módulo de micro-site pessoal de usuário.
*/
use Drupal\user\UserInterface;
/**
* Implements hook_theme().
*/
function site_users_microsite_theme(): array {
return [
'microsite_header_block' => [
'variables' => [
'photo_url' => NULL,
'photo_alt' => '',
'name' => NULL,
'bio' => NULL,
'email' => NULL,
'homepage' => NULL,
'lattes_id' => NULL,
'orcid_id' => NULL,
'mathscinet_id' => NULL,
'department' => NULL,
'department_url' => NULL,
'work_phone' => NULL,
],
],
];
}
/**
* Implements hook_preprocess_structural_pages_menu().
*
* Remove da árvore de navegação o nó configurado como homepage do microsite,
* já que esse conteúdo é exibido diretamente em /user/{id}.
*/
function site_users_microsite_preprocess_structural_pages_menu(array &$variables): void {
$user = site_users_get_microsite_user();
if ($user === NULL) {
return;
}
$homepage_nid = \Drupal::service('user.data')
->get('site_users_microsite', $user->id(), 'homepage_nid');
if (!$homepage_nid) {
return;
}
_site_users_microsite_remove_homepage_from_tree($variables['tree'], (int) $homepage_nid);
}
/**
* Remove recursivamente o nó homepage da árvore do structural_pages_menu.
*/
function _site_users_microsite_remove_homepage_from_tree(array &$items, int $homepage_nid): void {
foreach ($items as $key => $item) {
if ((int) ($item['id'] ?? 0) === $homepage_nid) {
unset($items[$key]);
continue;
}
if (!empty($item['children'])) {
_site_users_microsite_remove_homepage_from_tree($items[$key]['children'], $homepage_nid);
}
}
}
/**
* Implements hook_preprocess_block().
*
* Substitui o título do bloco "Título da Página" pelo título do nó homepage
* quando o usuário tiver ativado essa opção nas configurações do microsite.
* Sem nó homepage configurado (ou no fallback), mantém o comportamento padrão.
*/
function site_users_microsite_preprocess_block(&$variables): void {
if ($variables['plugin_id'] !== 'page_title_block') {
return;
}
$route_match = \Drupal::routeMatch();
$route_name = $route_match->getRouteName() ?? '';
// Rotas com título próprio não devem ser sobrescritas.
$excluded = [
'site_users_microsite.profile',
'site_users_microsite.settings',
'site_users_microsite.user_config',
];
$is_microsite = $route_name === 'entity.user.canonical'
|| str_starts_with($route_name, 'site_users_microsite.');
if (!$is_microsite || in_array($route_name, $excluded)) {
return;
}
$user = $route_match->getParameter('user');
if (!($user instanceof UserInterface)) {
return;
}
$userData = \Drupal::service('user.data');
if (!$userData->get('site_users_microsite', $user->id(), 'use_homepage_title')) {
return;
}
$homepage_nid = $userData->get('site_users_microsite', $user->id(), 'homepage_nid');
if (!$homepage_nid) {
return;
}
$node = \Drupal::entityTypeManager()->getStorage('node')->load($homepage_nid);
if ($node && $node->isPublished() && $node->access('view')) {
$variables['content']['#title'] = $node->label();
}
}
/**
* Implements hook_preprocess_page().
*
* Disponibiliza variáveis do usuário do micro-site para o tema.
*/
function site_users_microsite_preprocess_page(array &$variables): void {
$route_match = \Drupal::routeMatch();
$route_name = $route_match->getRouteName();
$is_microsite = $route_name === 'entity.user.canonical'
|| str_starts_with($route_name, 'site_users_microsite.');
if (!$is_microsite) {
return;
}
$user = $route_match->getParameter('user');
if (!($user instanceof UserInterface)) {
return;
}
$variables['microsite_user'] = $user;
$variables['microsite_user_name'] = $user->getDisplayName();
$variables['microsite_user_roles'] = $user->getRoles(TRUE);
$photo = site_users_get_default_photo($user);
if ($photo) {
$render = \Drupal::entityTypeManager()
->getViewBuilder('media')
->view($photo, 'thumbnail');
$variables['microsite_user_photo'] = \Drupal::service('renderer')
->renderInIsolation($render);
}
}

View File

@@ -0,0 +1,4 @@
administer site_users_microsite settings:
title: 'Administer User Microsite settings'
description: 'Configure which content types are available per role in micro-sites.'
restrict access: true

View File

@@ -0,0 +1,54 @@
site_users_microsite.profile:
path: '/user/{user}/profile'
defaults:
_controller: '\Drupal\site_users_microsite\Controller\MicrositeHomeController::profile'
_title_callback: '\Drupal\site_users_microsite\Controller\MicrositeHomeController::profileTitle'
requirements:
_entity_access: 'user.view'
user: \d+
options:
parameters:
user:
type: entity:user
site_users_microsite.content:
path: '/user/{user}/content'
defaults:
_controller: '\Drupal\site_users_microsite\Controller\MicrositeContentController::content'
_title_callback: '\Drupal\site_users_microsite\Controller\MicrositeContentController::title'
requirements:
_permission: 'access content'
user: \d+
options:
parameters:
user:
type: entity:user
site_users_microsite.user_config:
path: '/user/{user}/config'
defaults:
_form: '\Drupal\site_users_microsite\Form\MicrositeUserConfigForm'
_title: 'Microsite settings'
requirements:
_entity_access: 'user.update'
user: \d+
options:
parameters:
user:
type: entity:user
site_users_microsite.my_config:
path: '/user/microsite/config'
defaults:
_controller: '\Drupal\site_users_microsite\Controller\MicrositeHomeController::redirectToMyConfig'
_title: 'Microsite settings'
requirements:
_user_is_logged_in: 'TRUE'
site_users_microsite.settings:
path: '/admin/config/local-modules/site-users/microsite'
defaults:
_form: '\Drupal\site_users_microsite\Form\MicrositeSettingsForm'
_title: 'User Microsite Settings'
requirements:
_permission: 'administer site_users_microsite settings'

View File

@@ -0,0 +1,18 @@
services:
site_users_microsite.path_processor:
class: Drupal\site_users_microsite\PathProcessor\MicrositeSubpagePathProcessor
arguments: ['@path_alias.manager', '@language_manager']
tags:
- { name: path_processor_inbound, priority: 200 }
- { name: path_processor_outbound, priority: 200 }
site_users_microsite.theme_negotiator:
class: Drupal\site_users_microsite\Theme\MicrositeThemeNegotiator
arguments: ['@path_alias.manager', '@path.current']
tags:
- { name: theme_negotiator, priority: 100 }
site_users_microsite.route_subscriber:
class: Drupal\site_users_microsite\Routing\RouteSubscriber
tags:
- { name: event_subscriber }

View File

@@ -0,0 +1,83 @@
<?php
namespace Drupal\site_users_microsite\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\UserInterface;
/**
* Controller para listagem de conteúdo do micro-site do usuário.
*/
class MicrositeContentController extends ControllerBase {
/**
* Página de listagem de conteúdo do micro-site.
*/
public function content(UserInterface $user): array {
$allowed_types = $this->getAllowedContentTypes($user);
$cache = [
'contexts' => ['user'],
'tags' => [
'node_list',
'user:' . $user->id(),
'config:site_users_microsite.settings',
],
];
if (empty($allowed_types)) {
return [
'#markup' => $this->t('No content types are configured for this profile type.'),
'#cache' => $cache,
];
}
$nids = $this->entityTypeManager()->getStorage('node')
->getQuery()
->condition('uid', $user->id())
->condition('type', $allowed_types, 'IN')
->condition('status', 1)
->accessCheck(TRUE)
->sort('created', 'DESC')
->execute();
if (empty($nids)) {
return [
'#markup' => $this->t('No content published yet.'),
'#cache' => $cache,
];
}
$nodes = $this->entityTypeManager()->getStorage('node')->loadMultiple($nids);
$build = $this->entityTypeManager()->getViewBuilder('node')->viewMultiple($nodes, 'teaser');
$build['#cache'] = $cache;
return $build;
}
/**
* Callback de título para a página de conteúdo.
*/
public function title(UserInterface $user): TranslatableMarkup {
return $this->t("@name's content", ['@name' => $user->getDisplayName()]);
}
/**
* Retorna os tipos de conteúdo permitidos para os papéis do usuário.
*/
protected function getAllowedContentTypes(UserInterface $user): array {
$role_content_types = $this->config('site_users_microsite.settings')
->get('role_content_types') ?? [];
$allowed = [];
foreach ($user->getRoles() as $role_id) {
foreach ($role_content_types[$role_id] ?? [] as $type) {
$allowed[$type] = $type;
}
}
return array_keys($allowed);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Drupal\site_users_microsite\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\user\UserDataInterface;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Controller para a página inicial do micro-site do usuário.
*/
class MicrositeHomeController extends ControllerBase {
public function __construct(
protected UserDataInterface $userData,
) {}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('user.data'),
);
}
/**
* Página inicial do micro-site.
*
* Exibe o nó configurado pelo usuário ou, como fallback, o primeiro nó
* do tipo content_page publicado pelo usuário.
*/
public function home(UserInterface $user): array {
$cache = [
'tags' => [
'node_list:content_page',
'user:' . $user->id(),
'site_users_microsite_config:' . $user->id(),
],
'contexts' => ['route'],
];
$homepage_nid = $this->userData->get('site_users_microsite', $user->id(), 'homepage_nid');
if ($homepage_nid) {
$node = $this->entityTypeManager()->getStorage('node')->load($homepage_nid);
if ($node && $node->isPublished() && $node->access('view')) {
$build = $this->entityTypeManager()->getViewBuilder('node')->view($node, 'full');
$build['#cache'] = $cache;
return $build;
}
}
$nids = $this->entityTypeManager()->getStorage('node')
->getQuery()
->condition('uid', $user->id())
->condition('type', 'content_page')
->condition('status', 1)
->accessCheck(TRUE)
->range(0, 1)
->execute();
if (empty($nids)) {
return [
'#markup' => $this->t('No content published yet.'),
'#cache' => $cache,
];
}
$node = $this->entityTypeManager()->getStorage('node')->load(reset($nids));
$build = $this->entityTypeManager()->getViewBuilder('node')->view($node, 'full');
$build['#cache'] = $cache;
return $build;
}
/**
* Página de perfil do usuário em /user/{user}/profile.
*/
public function profile(UserInterface $user): array {
return $this->entityTypeManager()->getViewBuilder('user')->view($user, 'full');
}
/**
* Redireciona para a página de configuração do microsite do usuário atual.
*
* Usada pelo link do menu da conta, que não suporta parâmetros dinâmicos.
*/
public function redirectToMyConfig(): RedirectResponse {
return $this->redirect('site_users_microsite.user_config', [
'user' => $this->currentUser()->id(),
]);
}
/**
* Callback de título para a página inicial.
*/
public function title(UserInterface $user): TranslatableMarkup {
return $this->t('@name', ['@name' => $user->getDisplayName()]);
}
/**
* Callback de título para a página de perfil.
*/
public function profileTitle(UserInterface $user): TranslatableMarkup {
return $this->t('Perfil de @name', ['@name' => $user->getDisplayName()]);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Drupal\site_users_microsite\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\user\Entity\Role;
/**
* Formulário de configuração do módulo User Microsite.
*/
class MicrositeSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'site_users_microsite_settings_form';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames(): array {
return ['site_users_microsite.settings'];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$config = $this->config('site_users_microsite.settings');
$content_types = $this->getContentTypes();
$form['role_content_types'] = [
'#type' => 'fieldset',
'#title' => $this->t('Content types per role'),
'#description' => $this->t('Select which content types each role can publish in their micro-site.'),
'#tree' => TRUE,
];
if (empty($content_types)) {
$form['role_content_types']['_empty'] = [
'#markup' => '<p>' . $this->t('No content types found.') . '</p>',
];
}
else {
foreach (Role::loadMultiple() as $role_id => $role) {
if (in_array($role_id, ['anonymous', 'authenticated'])) {
continue;
}
$form['role_content_types'][$role_id] = [
'#type' => 'checkboxes',
'#title' => $role->label(),
'#options' => $content_types,
'#default_value' => $config->get('role_content_types.' . $role_id) ?? [],
];
}
}
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$config = $this->config('site_users_microsite.settings');
$raw = $form_state->getValue('role_content_types') ?? [];
foreach (Role::loadMultiple() as $role_id => $role) {
if (in_array($role_id, ['anonymous', 'authenticated']) || !isset($raw[$role_id])) {
continue;
}
$config->set('role_content_types.' . $role_id, array_values(array_filter($raw[$role_id])));
}
$config->save();
parent::submitForm($form, $form_state);
}
/**
* Retorna todos os tipos de conteúdo disponíveis.
*/
protected function getContentTypes(): array {
$options = [];
foreach (\Drupal::entityTypeManager()->getStorage('node_type')->loadMultiple() as $type) {
$options[$type->id()] = $type->label();
}
ksort($options);
return $options;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Drupal\site_users_microsite\Form;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\user\UserDataInterface;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Formulário de configuração do microsite pessoal do usuário.
*/
class MicrositeUserConfigForm extends FormBase {
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
protected UserDataInterface $userData,
) {}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('entity_type.manager'),
$container->get('user.data'),
);
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'site_users_microsite_user_config';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, ?UserInterface $user = NULL): array {
if ($user === NULL) {
return $form;
}
$form_state->set('user', $user);
$homepage_nid = $this->userData->get('site_users_microsite', $user->id(), 'homepage_nid');
$use_homepage_title = $this->userData->get('site_users_microsite', $user->id(), 'use_homepage_title');
$nids = $this->entityTypeManager->getStorage('node')
->getQuery()
->condition('uid', $user->id())
->condition('type', 'content_page')
->condition('status', 1)
->notExists('field_parent_page')
->accessCheck(TRUE)
->sort('title')
->execute();
$options = ['' => $this->t('— primeira página publicada —')];
if (!empty($nids)) {
$nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids);
foreach ($nodes as $nid => $node) {
$options[$nid] = $node->label();
}
}
$form['#attributes']['class'][] = 'microsite-form';
$form['#attached']['library'][] = 'site_users_microsite_theme/form';
$form['homepage_nid'] = [
'#type' => 'select',
'#title' => $this->t('Homepage content'),
'#description' => $this->t('Select which content to display on your microsite homepage.'),
'#options' => $options,
'#default_value' => $homepage_nid ?? '',
];
$form['use_homepage_title'] = [
'#type' => 'checkbox',
'#title' => $this->t('Use homepage content title as page title'),
'#description' => $this->t('When checked, the title of the selected homepage content replaces the default page title (your display name).'),
'#default_value' => $use_homepage_title ?? FALSE,
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save configuration'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$user = $form_state->get('user');
$nid = $form_state->getValue('homepage_nid');
if (empty($nid)) {
$this->userData->delete('site_users_microsite', $user->id(), 'homepage_nid');
}
else {
$this->userData->set('site_users_microsite', $user->id(), 'homepage_nid', (int) $nid);
}
if ($form_state->getValue('use_homepage_title')) {
$this->userData->set('site_users_microsite', $user->id(), 'use_homepage_title', TRUE);
}
else {
$this->userData->delete('site_users_microsite', $user->id(), 'use_homepage_title');
}
Cache::invalidateTags(['site_users_microsite_config:' . $user->id()]);
$this->messenger()->addStatus($this->t('Configuration saved.'));
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Drupal\site_users_microsite\PathProcessor;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
use Drupal\Core\PathProcessor\OutboundPathProcessorInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\path_alias\AliasManagerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Processa subpáginas do microsite para funcionar com aliases de usuário.
*
* Converte /user/{username}/{subpage} <-> /user/{uid}/{subpage} de forma
* transparente, complementando o alias exato /user/{username} do Pathauto.
*/
class MicrositeSubpagePathProcessor implements InboundPathProcessorInterface, OutboundPathProcessorInterface {
public function __construct(
private AliasManagerInterface $aliasManager,
private LanguageManagerInterface $languageManager,
) {}
/**
* {@inheritdoc}
*
* Converte /user/{username}/{subpage} para /user/{uid}/{subpage}.
*/
public function processInbound($path, Request $request) {
if (!preg_match('#^/user/([^/]+)(/.+)$#', $path, $matches)) {
return $path;
}
$segment = $matches[1];
$rest = $matches[2];
// Segmento numérico já é UID — nada a fazer.
if (is_numeric($segment)) {
return $path;
}
$alias = '/user/' . $segment;
$system_path = $this->lookupSystemPath($alias);
if ($system_path !== $alias && preg_match('#^/user/\d+$#', $system_path)) {
return $system_path . $rest;
}
return $path;
}
/**
* {@inheritdoc}
*
* Converte /user/{uid}/{subpage} para /user/{username}/{subpage}.
*/
public function processOutbound($path, &$options = [], ?Request $request = NULL, ?BubbleableMetadata $bubbleable_metadata = NULL) {
if (!preg_match('#^/user/(\d+)(/.+)$#', $path, $matches)) {
return $path;
}
$uid = $matches[1];
$rest = $matches[2];
$alias = $this->aliasManager->getAliasByPath('/user/' . $uid);
if ($alias !== '/user/' . $uid) {
if ($bubbleable_metadata) {
$bubbleable_metadata->addCacheContexts(['url.path']);
}
return $alias . $rest;
}
return $path;
}
/**
* Busca o caminho interno para um alias tentando todos os idiomas.
*
* O alias manager só faz fallback para LANGCODE_NOT_SPECIFIED; aliases
* armazenados com 'en' não são encontrados quando o idioma atual é 'pt-br'.
*/
private function lookupSystemPath(string $alias): string {
$langcodes = array_keys($this->languageManager->getLanguages());
$langcodes[] = LanguageInterface::LANGCODE_NOT_SPECIFIED;
foreach ($langcodes as $langcode) {
$system_path = $this->aliasManager->getPathByAlias($alias, $langcode);
if ($system_path !== $alias) {
return $system_path;
}
}
return $alias;
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace Drupal\site_users_microsite\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Bloco de cabeçalho do microsite com informações do usuário.
*
* Exibe foto (circular), nome, biografia e
* informações de contato (telefone, e-mail).
*
* @Block(
* id = "site_users_microsite_header",
* admin_label = @Translation("Microsite — cabeçalho do usuário"),
* category = @Translation("Site Users")
* )
*/
class MicrositeHeaderBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
protected RouteMatchInterface $routeMatch,
protected EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('current_route_match'),
$container->get('entity_type.manager'),
);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return ['label_display' => '0'] + parent::defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function build(): array {
$user = $this->getUser();
if (!$user instanceof UserInterface) {
return [];
}
return [
'#theme' => 'microsite_header_block',
'#photo_url' => $this->getPhotoUrl($user),
'#photo_alt' => $this->getPhotoAlt($user),
'#name' => $this->getFieldValue($user, 'field_user_name') ?: $user->getDisplayName(),
'#bio' => $this->getProcessedValue($user, 'field_user_bio'),
'#email' => $user->getEmail(),
'#homepage' => $this->getFieldUri($user, 'field_user_homepage'),
'#lattes_id' => $this->getFieldValue($user, 'field_user_id_lattes'),
'#orcid_id' => $this->getFieldValue($user, 'field_user_orcid'),
'#mathscinet_id' => $this->getFieldValue($user, 'field_user_mathscinetid'),
'#department' => $this->getReferencedEntityLabel($user, 'field_user_department'),
'#department_url' => $this->getReferencedEntityUrl($user, 'field_user_department'),
'#work_phone' => $this->getFieldValue($user, 'field_user_work_phone'),
'#cache' => [
'tags' => $user->getCacheTags(),
'contexts' => ['route'],
],
];
}
/**
* Retorna o usuário da rota atual.
*
* Primeiro tenta o parâmetro 'user' da rota (rotas próprias do microsite).
* Caso não exista (ex: rota entity.node.canonical acessada via alias
* /user/{id}/...), extrai o ID do alias do caminho atual.
*/
protected function getUser(): ?UserInterface {
$user = $this->routeMatch->getParameter('user');
if ($user instanceof UserInterface) {
return $user;
}
if (is_numeric($user)) {
return $this->entityTypeManager->getStorage('user')->load($user);
}
if (function_exists('site_users_get_microsite_user')) {
return site_users_get_microsite_user();
}
return NULL;
}
/**
* Retorna a URL absoluta da foto padrão do usuário.
*/
protected function getPhotoUrl(UserInterface $user): ?string {
if (!function_exists('site_users_get_default_photo')) {
return NULL;
}
$media = site_users_get_default_photo($user);
if (!$media) {
return NULL;
}
$source_field = $media->getSource()->getConfiguration()['source_field'];
if (!$media->hasField($source_field) || $media->get($source_field)->isEmpty()) {
return NULL;
}
$file = $media->get($source_field)->entity;
if (!$file) {
return NULL;
}
return \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri());
}
/**
* Retorna o texto alternativo da foto padrão.
*/
protected function getPhotoAlt(UserInterface $user): string {
if (!function_exists('site_users_get_default_photo')) {
return '';
}
$media = site_users_get_default_photo($user);
return $media ? $media->label() : '';
}
/**
* Retorna a URI de um campo link do usuário.
*/
protected function getFieldUri(UserInterface $user, string $field_name): ?string {
if ($user->hasField($field_name) && !$user->get($field_name)->isEmpty()) {
return $user->get($field_name)->uri;
}
return NULL;
}
/**
* Retorna o label da entidade referenciada por um campo entity_reference.
*/
protected function getReferencedEntityLabel(UserInterface $user, string $field_name): ?string {
if (!$user->hasField($field_name) || $user->get($field_name)->isEmpty()) {
return NULL;
}
$entity = $user->get($field_name)->entity;
return $entity ? $entity->label() : NULL;
}
/**
* Retorna a URL canônica da entidade referenciada por um campo entity_reference.
*/
protected function getReferencedEntityUrl(UserInterface $user, string $field_name): ?string {
if (!$user->hasField($field_name) || $user->get($field_name)->isEmpty()) {
return NULL;
}
$entity = $user->get($field_name)->entity;
if (!$entity || !$entity->hasLinkTemplate('canonical')) {
return NULL;
}
try {
return $entity->toUrl('canonical')->toString();
}
catch (\Exception $e) {
return NULL;
}
}
/**
* Retorna o valor de texto de um campo do usuário.
*/
protected function getFieldValue(UserInterface $user, string $field_name): ?string {
if ($user->hasField($field_name) && !$user->get($field_name)->isEmpty()) {
return $user->get($field_name)->value;
}
return NULL;
}
/**
* Retorna o valor processado (HTML filtrado) de um campo text_long.
*
* Usa ->processed, que aplica o formato de texto configurado e retorna um
* objeto Markup — o Twig renderiza como HTML sem escape adicional.
*/
protected function getProcessedValue(UserInterface $user, string $field_name): ?string {
if ($user->hasField($field_name) && !$user->get($field_name)->isEmpty()) {
return $user->get($field_name)->processed;
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function getCacheTags(): array {
$user = $this->getUser();
if ($user instanceof UserInterface) {
return Cache::mergeTags(parent::getCacheTags(), $user->getCacheTags());
}
return parent::getCacheTags();
}
/**
* {@inheritdoc}
*/
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Drupal\site_users_microsite\Routing;
use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;
/**
* Sobrescreve a rota canônica do usuário para exibir o micro-site.
*/
class RouteSubscriber extends RouteSubscriberBase {
/**
* {@inheritdoc}
*/
protected function alterRoutes(RouteCollection $collection): void {
if ($route = $collection->get('entity.user.canonical')) {
$route->setDefault('_controller', '\Drupal\site_users_microsite\Controller\MicrositeHomeController::home');
$route->setDefault('_title_callback', '\Drupal\site_users_microsite\Controller\MicrositeHomeController::title');
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Drupal\site_users_microsite\Theme;
use Drupal\Core\Path\CurrentPathStack;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Theme\ThemeNegotiatorInterface;
use Drupal\path_alias\AliasManagerInterface;
use Drupal\user\UserInterface;
/**
* Aplica o tema microsite nas rotas de perfil de usuário e do micro-site.
*/
class MicrositeThemeNegotiator implements ThemeNegotiatorInterface {
public function __construct(
private AliasManagerInterface $aliasManager,
private CurrentPathStack $currentPath,
) {}
/**
* {@inheritdoc}
*/
public function applies(RouteMatchInterface $route_match): bool {
$route_name = $route_match->getRouteName() ?? '';
// Rotas administrativas e de edição nunca recebem o tema do microsite.
$excluded = [
'site_users_microsite.settings',
'site_users_microsite.user_config',
'site_users_microsite.my_config',
];
if (in_array($route_name, $excluded, TRUE)) {
return FALSE;
}
foreach (['entity.user.edit_', 'entity.user.cancel', 'user.admin'] as $prefix) {
if (str_starts_with($route_name, $prefix)) {
return FALSE;
}
}
// Rota canônica e rotas próprias do microsite.
if ($route_name === 'entity.user.canonical') {
return TRUE;
}
if (str_starts_with($route_name, 'site_users_microsite.')) {
return TRUE;
}
// Qualquer rota com parâmetro 'user' (entidade) sob /user/{user}/.
$user = $route_match->getParameter('user');
if ($user instanceof UserInterface) {
$route = $route_match->getRouteObject();
$path = $route ? $route->getPath() : '';
if (str_starts_with($path, '/user/{user}/')) {
return TRUE;
}
}
// Nós cujo alias começa com /user/{uid}/ (ex.: structural_pages, blog).
if ($route_name === 'entity.node.canonical') {
$node = $route_match->getParameter('node');
if ($node) {
$nid = is_object($node) ? $node->id() : $node;
$alias = $this->aliasManager->getAliasByPath('/node/' . $nid);
if (preg_match('#^/user/\d+/#', $alias)) {
return TRUE;
}
}
}
// Qualquer rota cujo path atual (já processado) seja /user/{uid}/...
// Cobre Views e outras rotas que não expõem parâmetro 'user' na rota.
if (preg_match('#^/user/\d+/#', $this->currentPath->getPath())) {
return TRUE;
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function determineActiveTheme(RouteMatchInterface $route_match): ?string {
return 'site_users_microsite_theme';
}
}

View File

@@ -0,0 +1,119 @@
{#
/**
* @file
* Template do bloco de cabeçalho do microsite.
*
* Variáveis:
* - photo_url: URL absoluta da foto padrão (string|null).
* - photo_alt: Texto alternativo da foto (string).
* - name: Nome completo do usuário (string).
* - roles: Array de rótulos das roles do usuário (string[]).
* - bio: Biografia (string|null).
* - phone: Telefone (string|null).
* - email: E-mail (string|null).
* - homepage: URL da página pessoal (string|null).
* - lattes_id: ID do Currículo Lattes (string|null).
* - orcid_id: ORCID iD (string|null).
* - mathscinet_id: MathSciNet Author ID (string|null).
*/
#}
<div class="msite-header-block">
<div class="msite-header-block__photo-wrap">
{% if photo_url %}
<img
class="msite-header-block__photo"
src="{{ photo_url }}"
alt="{{ photo_alt }}"
/>
{% else %}
<div class="msite-header-block__photo msite-header-block__photo--initials" aria-hidden="true">
{{ name|first|upper }}
</div>
{% endif %}
</div>
<div class="msite-header-block__info">
<h1 class="msite-header-block__name">{{ name }}</h1>
{% if department %}
<div class="msite-header-block__department">
{% if department_url %}
<a href="{{ department_url }}">{{ department }}</a>
{% else %}
{{ department }}
{% endif %}
</div>
{% endif %}
{% if bio %}
<div class="msite-header-block__bio">{{ bio|raw }}</div>
{% endif %}
{% if work_phone or email %}
<ul class="msite-header-block__contact">
{% if work_phone %}
<li class="msite-header-block__contact-item">
<span class="msite-header-block__contact-label">{{ 'Telefone'|t }}:</span>
<a href="tel:{{ work_phone }}">{{ work_phone }}</a>
</li>
{% endif %}
{% if email %}
<li class="msite-header-block__contact-item">
<span class="msite-header-block__contact-label">{{ 'E-mail'|t }}:</span>
<a href="mailto:{{ email }}">{{ email }}</a>
</li>
{% endif %}
</ul>
{% endif %}
{% if homepage or lattes_id or orcid_id or mathscinet_id %}
<div class="msite-header-block__academic-links">
{% if homepage %}
<a href="{{ homepage }}"
class="msite-header-block__academic-link msite-header-block__academic-link--homepage"
target="_blank" rel="noopener noreferrer"
title="{{ 'Página pessoal'|t }}"
aria-label="{{ 'Página pessoal'|t }}">
{{ site_tools_academic_icon('home') }}
</a>
{% endif %}
{% if lattes_id %}
<a href="https://lattes.cnpq.br/{{ lattes_id }}"
class="msite-header-block__academic-link msite-header-block__academic-link--lattes"
target="_blank" rel="noopener noreferrer"
title="{{ 'Currículo Lattes'|t }}"
aria-label="{{ 'Currículo Lattes'|t }}">
{{ site_tools_academic_icon('lattes') }}
</a>
{% endif %}
{% if orcid_id %}
<a href="https://orcid.org/{{ orcid_id }}"
class="msite-header-block__academic-link msite-header-block__academic-link--orcid"
target="_blank" rel="noopener noreferrer"
title="{{ 'Perfil ORCID'|t }}"
aria-label="{{ 'Perfil ORCID'|t }}">
{{ site_tools_academic_icon('orcid') }}
</a>
{% endif %}
{% if mathscinet_id %}
<a href="https://mathscinet.ams.org/mathscinet/author?AuthorID={{ mathscinet_id }}"
class="msite-header-block__academic-link msite-header-block__academic-link--mathscinet"
target="_blank" rel="noopener noreferrer"
title="{{ 'Perfil MathSciNet'|t }}"
aria-label="{{ 'Perfil MathSciNet'|t }}">
{{ site_tools_academic_icon('mathscinet') }}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,12 @@
{
"name": "imecc/site_users_user_content",
"description": "Provides User entity support as parent type for Structural Pages module.",
"type": "drupal-module",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
"drupal/core": "^10.3 || ^11",
"imecc/site_users": "*",
"imecc/structural_pages": "*"
}
}

View File

@@ -0,0 +1,8 @@
name: 'Site Users User Content'
type: module
description: 'Provides User entity support as parent type for Structural Pages module.'
package: 'Site Users'
core_version_requirement: ^10.3 || ^11
dependencies:
- structural_pages:structural_pages
- site_users:site_users

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Drupal\site_users_user_content\Plugin\ParentEntityHandler;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\structural_pages\Attribute\ParentEntityHandler;
use Drupal\structural_pages\ParentEntityHandler\ParentEntityHandlerBase;
/**
* Handler for user entities.
*
* Allows content_page nodes to use a user entity as their parent, enabling
* personal microsite content organisation without a site section.
*/
#[ParentEntityHandler(
id: 'user',
label: new TranslatableMarkup('Users (user)'),
entity_type_id: 'user',
clears_site_section: TRUE,
sort_field: 'name',
weight: 30,
)]
class UserHandler extends ParentEntityHandlerBase {}

View File

@@ -2,7 +2,7 @@ name: Site Users
type: module type: module
description: 'Site user customizations, including fields and templates.' description: 'Site user customizations, including fields and templates.'
core_version_requirement: ^10 || ^11 core_version_requirement: ^10 || ^11
package: Custom package: 'Site Users'
dependencies: dependencies:
- drupal:user - drupal:user
- drupal:telephone - drupal:telephone

View File

@@ -194,6 +194,79 @@ function site_users_install() {
} }
} }
/**
* Remove mídias LDAP geradas sem UID (URI ldap_photo_.jpg/png) de todos os
* usuários e apaga os arquivos e mídias correspondentes.
*
* Causa: durante provisionamento LDAP, $account->id() era vazio antes do
* primeiro save, gerando URI única compartilhada por todos os usuários.
*/
function site_users_update_10011() {
$file_storage = \Drupal::entityTypeManager()->getStorage('file');
$media_storage = \Drupal::entityTypeManager()->getStorage('media');
$user_storage = \Drupal::entityTypeManager()->getStorage('user');
// Localiza arquivos com URI sem UID (ldap_photo_.jpg, ldap_photo_.png…).
$fids = $file_storage->getQuery()
->condition('uri', 'public://ldap_photos/ldap_photo_.', 'STARTS_WITH')
->accessCheck(FALSE)
->execute();
if (empty($fids)) {
return t('Nenhum arquivo LDAP sem UID encontrado.');
}
$removed_media = 0;
$affected_users = 0;
foreach ($fids as $fid) {
$medias = $media_storage->loadByProperties([
'bundle' => 'image',
'field_media_image.target_id' => $fid,
]);
foreach ($medias as $media) {
$mid = (int) $media->id();
$uids = $user_storage->getQuery()
->condition('field_user_photos.target_id', $mid)
->accessCheck(FALSE)
->execute();
foreach ($uids as $uid) {
$user = $user_storage->load($uid);
if (!$user) {
continue;
}
$photos = array_column($user->get('field_user_photos')->getValue(), 'target_id');
$photos = array_values(array_filter($photos, fn($id) => (int) $id !== $mid));
$user->set('field_user_photos', array_map(fn($id) => ['target_id' => $id], $photos));
if ((int) $user->get('field_user_default_photo')->target_id === $mid) {
$user->set('field_user_default_photo', empty($photos) ? NULL : $photos[0]);
}
$user->save();
$affected_users++;
}
$media->delete();
$removed_media++;
}
$file = $file_storage->load($fid);
if ($file) {
$file->delete();
}
}
return t('Removidas @m mídias sem UID de @u usuários.', [
'@m' => $removed_media,
'@u' => $affected_users,
]);
}
/** /**
* Adiciona o campo field_user_default_photo para seleção de foto padrão. * Adiciona o campo field_user_default_photo para seleção de foto padrão.
*/ */
@@ -470,10 +543,10 @@ function site_users_update_10006() {
$form_display->removeComponent('field_user_selected_view_mode')->save(); $form_display->removeComponent('field_user_selected_view_mode')->save();
} }
// 6. Remover de todos os view displays existentes. // 6. Remover de todos os view displays de usuário existentes.
$displays = $display_storage->loadMultiple(); $displays = $display_storage->loadByProperties(['targetEntityType' => 'user']);
foreach ($displays as $display) { foreach ($displays as $display) {
if ($display->getTargetEntityTypeId() === 'user' && $display->getComponent('field_user_selected_view_mode')) { if ($display->getComponent('field_user_selected_view_mode')) {
$display->removeComponent('field_user_selected_view_mode')->save(); $display->removeComponent('field_user_selected_view_mode')->save();
} }
} }
@@ -508,6 +581,192 @@ function site_users_update_10007() {
return t('field_user_id_lattes alterado para BIGINT para suportar IDs Lattes de 16 dígitos.'); return t('field_user_id_lattes alterado para BIGINT para suportar IDs Lattes de 16 dígitos.');
} }
/**
* Adds the field_user_mathscinetid field for researcher MathSciNet ID.
*/
function site_users_update_10008() {
// Create field storage if it does not exist.
if (!FieldStorageConfig::loadByName('user', 'field_user_mathscinetid')) {
FieldStorageConfig::create([
'field_name' => 'field_user_mathscinetid',
'entity_type' => 'user',
'type' => 'integer',
'settings' => [
'unsigned' => FALSE,
'size' => 'normal',
],
'cardinality' => 1,
'translatable' => TRUE,
])->save();
}
// Create field instance if it does not exist.
if (!FieldConfig::loadByName('user', 'user', 'field_user_mathscinetid')) {
FieldConfig::create([
'field_name' => 'field_user_mathscinetid',
'entity_type' => 'user',
'bundle' => 'user',
'label' => 'MathSciNet ID',
'description' => 'MathSciNet identifier of the researcher.',
'required' => FALSE,
'settings' => [
'min' => NULL,
'max' => NULL,
'prefix' => '',
'suffix' => '',
],
])->save();
}
return t('MathSciNet ID field created successfully.');
}
/**
* Cria o campo field_user_homepage para página pessoal do usuário.
*/
function site_users_update_10009() {
if (!FieldStorageConfig::loadByName('user', 'field_user_homepage')) {
FieldStorageConfig::create([
'field_name' => 'field_user_homepage',
'entity_type' => 'user',
'type' => 'link',
'settings' => [
'title' => 0,
'link_type' => 16,
],
'cardinality' => 1,
'translatable' => TRUE,
])->save();
}
if (!FieldConfig::loadByName('user', 'user', 'field_user_homepage')) {
FieldConfig::create([
'field_name' => 'field_user_homepage',
'entity_type' => 'user',
'bundle' => 'user',
'label' => 'Personal homepage',
'description' => 'URL of the user personal homepage.',
'required' => FALSE,
'settings' => [
'title' => 0,
'link_type' => 16,
],
])->save();
}
$form_display = EntityFormDisplay::load('user.user.default');
if ($form_display && !$form_display->getComponent('field_user_homepage')) {
$form_display->setComponent('field_user_homepage', [
'type' => 'link_default',
'weight' => 12,
'settings' => [
'placeholder_url' => 'https://',
'placeholder_title' => '',
],
'region' => 'content',
])->save();
}
$view_display = EntityViewDisplay::load('user.user.default');
if ($view_display && !$view_display->getComponent('field_user_homepage')) {
$view_display->setComponent('field_user_homepage', [
'type' => 'link',
'weight' => 12,
'label' => 'above',
'settings' => [
'trim_length' => 80,
'url_only' => FALSE,
'url_plain' => FALSE,
'rel' => 'noopener noreferrer',
'target' => '_blank',
],
'region' => 'content',
])->save();
}
return t('Campo field_user_homepage criado com sucesso.');
}
/**
* Remove fotos LDAP placeholder (abaixo do tamanho mínimo configurado).
*
* Limpa field_user_photos e field_user_default_photo dos usuários afetados
* e apaga as entidades de mídia e arquivo correspondentes.
*/
function site_users_update_10010() {
$min_size = (int) (\Drupal::config('site_users.settings')->get('photos.ldap_min_photo_size') ?? 10240);
// Busca arquivos LDAP abaixo do tamanho mínimo.
$fids = \Drupal::entityTypeManager()->getStorage('file')->getQuery()
->condition('uri', 'public://ldap_photos/', 'STARTS_WITH')
->condition('filesize', $min_size, '<')
->accessCheck(FALSE)
->execute();
if (empty($fids)) {
return t('Nenhuma foto LDAP placeholder encontrada.');
}
$media_storage = \Drupal::entityTypeManager()->getStorage('media');
$user_storage = \Drupal::entityTypeManager()->getStorage('user');
$removed_media = 0;
$affected_users = 0;
foreach ($fids as $fid) {
// Localiza a entidade de mídia que usa este arquivo.
$medias = $media_storage->loadByProperties([
'bundle' => 'image',
'field_media_image.target_id' => $fid,
]);
foreach ($medias as $media) {
$mid = (int) $media->id();
// Busca usuários que têm esta mídia em field_user_photos.
$uids = $user_storage->getQuery()
->condition('field_user_photos.target_id', $mid)
->accessCheck(FALSE)
->execute();
foreach ($uids as $uid) {
/** @var \Drupal\user\UserInterface $user */
$user = $user_storage->load($uid);
if (!$user) {
continue;
}
// Remove a mídia de field_user_photos.
$photos = array_column($user->get('field_user_photos')->getValue(), 'target_id');
$photos = array_values(array_filter($photos, fn($id) => (int) $id !== $mid));
$user->set('field_user_photos', array_map(fn($id) => ['target_id' => $id], $photos));
// Limpa field_user_default_photo se apontar para esta mídia.
$default_mid = $user->get('field_user_default_photo')->target_id;
if ((int) $default_mid === $mid) {
$user->set('field_user_default_photo', empty($photos) ? NULL : $photos[0]);
}
$user->save();
$affected_users++;
}
$media->delete();
$removed_media++;
}
// Apaga o arquivo gerenciado.
$file = \Drupal::entityTypeManager()->getStorage('file')->load($fid);
if ($file) {
$file->delete();
}
}
return t('Removidas @m mídias placeholder de @u usuários.', [
'@m' => $removed_media,
'@u' => $affected_users,
]);
}
/** /**
* Corrige mapeamentos LDAP com campos de string nulos na config ativa. * Corrige mapeamentos LDAP com campos de string nulos na config ativa.
*/ */

View File

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

View File

@@ -4,3 +4,10 @@ site_users.settings:
route_name: site_users.settings route_name: site_users.settings
parent: site_tools.admin_config parent: site_tools.admin_config
weight: 10 weight: 10
site_users.add_content:
title: 'Adicionar'
route_name: site_users.add_content
menu_name: account
weight: -5
expanded: true

View File

@@ -0,0 +1,5 @@
site_users.settings_tab:
route_name: site_users.settings
title: 'General'
base_route: site_users.settings
weight: 0

View File

@@ -11,6 +11,7 @@ use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Menu\MenuLinkDefault;
use Drupal\Core\Session\AccountInterface; use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url; use Drupal\Core\Url;
use Drupal\field\FieldConfigInterface; use Drupal\field\FieldConfigInterface;
@@ -32,13 +33,7 @@ function site_users_theme($existing, $type, $theme, $path) {
'template' => 'user--restricted', 'template' => 'user--restricted',
'base hook' => 'user', 'base hook' => 'user',
], ],
'site_users_info_block' => [
'template' => 'site-user-info-block',
'variables' => [
'user_info' => [],
'user' => NULL,
],
],
]; ];
} }
@@ -327,8 +322,18 @@ function site_users_form_user_register_form_alter(&$form, FormStateInterface $fo
/** /**
* Adiciona o seletor de foto padrão ao formulário. * 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) { 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 */ /** @var \Drupal\user\UserInterface $user */
$user = $form_state->getFormObject()->getEntity(); $user = $form_state->getFormObject()->getEntity();
@@ -525,11 +530,35 @@ function site_users_user_format_name_alter(&$name, $account) {
} }
} }
/**
* Retorna o usuário dono do microsite para a requisição atual.
*
* Tenta o parâmetro 'user' da rota (rotas próprias do microsite) e, se não
* encontrar, extrai o ID do alias do caminho atual (ex: /user/229/projetos).
*/
function site_users_get_microsite_user(): ?UserInterface {
$user = \Drupal::routeMatch()->getParameter('user');
if ($user instanceof UserInterface) {
return $user;
}
if (is_numeric($user)) {
return \Drupal::entityTypeManager()->getStorage('user')->load($user);
}
$alias = \Drupal::service('path_alias.manager')
->getAliasByPath(\Drupal::service('path.current')->getPath());
if (preg_match('#^/user/(\d+)(/|$)#', $alias, $matches)) {
return \Drupal::entityTypeManager()->getStorage('user')->load($matches[1]);
}
return NULL;
}
/** /**
* Implements hook_site_tools_share_links(). * Implements hook_site_tools_share_links().
*/ */
function site_users_site_tools_share_links(): array { function site_users_site_tools_share_links(): array {
$user = \Drupal::routeMatch()->getParameter('user'); $user = site_users_get_microsite_user();
if (!($user instanceof UserInterface)) { if (!($user instanceof UserInterface)) {
return []; return [];
@@ -600,3 +629,44 @@ function site_users_get_default_photo(UserInterface $user): ?MediaInterface {
return NULL; return NULL;
} }
/**
* Implements hook_menu_links_discovered_alter().
*
* Adiciona dinamicamente os subitens do menu "Adicionar" configurados em
* site_users.settings:add_content_links.
*/
function site_users_menu_links_discovered_alter(array &$links): void {
$items = \Drupal::config('site_users.settings')->get('add_content_links') ?? [];
foreach ($items as $item) {
if (empty($item['route_name']) || empty($item['label'])) {
continue;
}
// ID estável derivado da rota e dos parâmetros.
$parts = [preg_replace('/[^a-z0-9]/', '_', strtolower($item['route_name']))];
foreach ($item['route_parameters'] ?? [] as $value) {
$parts[] = preg_replace('/[^a-z0-9]/', '_', strtolower((string) $value));
}
$id = 'site_users.add_content_child.' . implode('_', $parts);
$links[$id] = [
'id' => $id,
'title' => $item['label'],
'route_name' => $item['route_name'],
'route_parameters' => $item['route_parameters'] ?? [],
'menu_name' => 'account',
'parent' => 'site_users.add_content',
'weight' => (int) ($item['weight'] ?? 0),
'provider' => 'site_users',
'class' => MenuLinkDefault::class,
'form_class' => 'Drupal\Core\Menu\Form\MenuLinkDefaultForm',
'metadata' => [],
'options' => [],
'expanded' => FALSE,
'enabled' => TRUE,
'discovered' => TRUE,
];
}
}

View File

@@ -5,3 +5,11 @@ site_users.settings:
_title: 'Site Users Settings' _title: 'Site Users Settings'
requirements: requirements:
_permission: 'administer site_users settings' _permission: 'administer site_users settings'
site_users.add_content:
path: '/add-content'
defaults:
_controller: '\Drupal\site_users\Controller\AddContentController::redirectToFirst'
_title: 'Adicionar'
requirements:
_custom_access: 'site_users.add_content_access::access'

View File

@@ -6,3 +6,9 @@ services:
- '@entity_type.manager' - '@entity_type.manager'
- '@file.repository' - '@file.repository'
- '@file_system' - '@file_system'
site_users.add_content_access:
class: Drupal\site_users\Access\AddContentAccessCheck
arguments:
- '@config.factory'
- '@access_manager'

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Drupal\site_users\Access;
use Drupal\Core\Access\AccessManagerInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Grants access to the "Adicionar" parent menu route when any child is accessible.
*/
class AddContentAccessCheck implements AccessInterface {
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
private readonly AccessManagerInterface $accessManager,
) {}
/**
* Returns allowed if the user can access at least one configured add route.
*/
public function access(AccountInterface $account): AccessResultInterface {
$items = $this->configFactory
->get('site_users.settings')
->get('add_content_links') ?? [];
foreach ($items as $item) {
if (empty($item['route_name'])) {
continue;
}
$params = $item['route_parameters'] ?? [];
if ($this->accessManager->checkNamedRoute($item['route_name'], $params, $account)) {
return AccessResult::allowed()
->addCacheContexts(['user.permissions', 'user.roles']);
}
}
return AccessResult::forbidden()
->addCacheContexts(['user.permissions', 'user.roles']);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Drupal\site_users\Controller;
use Drupal\Core\Access\AccessManagerInterface;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Redirects to the first accessible add-content route for the current user.
*/
class AddContentController extends ControllerBase {
public function __construct(
private readonly AccessManagerInterface $accessManager,
) {}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('access_manager'),
);
}
/**
* Redirects to the first accessible add route, or to the front page.
*/
public function redirectToFirst(): RedirectResponse {
$items = $this->config('site_users.settings')
->get('add_content_links') ?? [];
$account = $this->currentUser();
foreach ($items as $item) {
if (empty($item['route_name'])) {
continue;
}
$params = $item['route_parameters'] ?? [];
if ($this->accessManager->checkNamedRoute($item['route_name'], $params, $account)) {
return $this->redirect($item['route_name'], $params);
}
}
return $this->redirect('<front>');
}
}

View File

@@ -118,6 +118,20 @@ class SiteUsersSettingsForm extends ConfigFormBase {
], ],
]; ];
$form['photos']['photos_ldap_min_photo_size'] = [
'#type' => 'number',
'#title' => $this->t('Minimum LDAP photo size (bytes)'),
'#description' => $this->t('Photos smaller than this size are ignored during LDAP sync, avoiding placeholder images. Default: 10240 (10 KB).'),
'#default_value' => $config->get('photos.ldap_min_photo_size') ?? 10240,
'#min' => 0,
'#required' => TRUE,
'#states' => [
'visible' => [
':input[name="photos_ldap_sync_enabled"]' => ['checked' => TRUE],
],
],
];
// Fieldset para campos editáveis pelo próprio usuário. // Fieldset para campos editáveis pelo próprio usuário.
$form['user_editable_fields'] = [ $form['user_editable_fields'] = [
'#type' => 'fieldset', '#type' => 'fieldset',
@@ -177,6 +191,66 @@ class SiteUsersSettingsForm extends ConfigFormBase {
} }
} }
// Fieldset para itens do menu "Adicionar".
$form['add_content_links'] = [
'#type' => 'fieldset',
'#title' => $this->t('Add content menu items'),
'#description' => $this->t('Configure items shown under the "Adicionar" entry in the account menu. Each item points to an entity add route. Leave "Label" empty to remove the row.'),
'#tree' => TRUE,
];
$saved_items = $config->get('add_content_links') ?? [];
// Append one blank row for adding a new item.
$saved_items[] = ['label' => '', 'route_name' => '', 'route_parameters' => [], 'weight' => 0];
$form['add_content_links']['table'] = [
'#type' => 'table',
'#header' => [
$this->t('Label'),
$this->t('Route name'),
$this->t('Parameter name'),
$this->t('Parameter value'),
$this->t('Weight'),
],
'#empty' => $this->t('No items yet. Fill in the row below to add one.'),
];
foreach ($saved_items as $delta => $item) {
$params = $item['route_parameters'] ?? [];
$param_name = array_key_first($params) ?? '';
$param_value = $params[$param_name] ?? '';
$form['add_content_links']['table'][$delta]['label'] = [
'#type' => 'textfield',
'#default_value' => $item['label'] ?? '',
'#size' => 20,
'#placeholder' => $this->t('e.g. Artigo'),
];
$form['add_content_links']['table'][$delta]['route_name'] = [
'#type' => 'textfield',
'#default_value' => $item['route_name'] ?? '',
'#size' => 30,
'#placeholder' => 'e.g. node.add',
];
$form['add_content_links']['table'][$delta]['param_name'] = [
'#type' => 'textfield',
'#default_value' => $param_name,
'#size' => 20,
'#placeholder' => 'e.g. node_type',
];
$form['add_content_links']['table'][$delta]['param_value'] = [
'#type' => 'textfield',
'#default_value' => $param_value,
'#size' => 20,
'#placeholder' => 'e.g. article',
];
$form['add_content_links']['table'][$delta]['weight'] = [
'#type' => 'number',
'#default_value' => (int) ($item['weight'] ?? 0),
'#size' => 4,
];
}
return parent::buildForm($form, $form_state); return parent::buildForm($form, $form_state);
} }
@@ -210,7 +284,8 @@ class SiteUsersSettingsForm extends ConfigFormBase {
$config $config
->set('photos.max_count', $form_state->getValue('photos_max_count')) ->set('photos.max_count', $form_state->getValue('photos_max_count'))
->set('photos.ldap_sync_enabled', (bool) $form_state->getValue('photos_ldap_sync_enabled')) ->set('photos.ldap_sync_enabled', (bool) $form_state->getValue('photos_ldap_sync_enabled'))
->set('photos.ldap_attribute', $form_state->getValue('photos_ldap_attribute')); ->set('photos.ldap_attribute', $form_state->getValue('photos_ldap_attribute'))
->set('photos.ldap_min_photo_size', (int) $form_state->getValue('photos_ldap_min_photo_size'));
$definitions = \Drupal::service('entity_field.manager') $definitions = \Drupal::service('entity_field.manager')
->getFieldDefinitions('user', 'user'); ->getFieldDefinitions('user', 'user');
@@ -230,6 +305,32 @@ class SiteUsersSettingsForm extends ConfigFormBase {
} }
} }
// Salvar add_content_links: ignorar linhas sem label ou route_name.
$links_raw = $form_state->getValue(['add_content_links', 'table']) ?? [];
$add_content_links = [];
foreach ($links_raw as $row) {
$label = trim($row['label'] ?? '');
$route_name = trim($row['route_name'] ?? '');
if ($label === '' || $route_name === '') {
continue;
}
$params = [];
$param_name = trim($row['param_name'] ?? '');
$param_value = trim($row['param_value'] ?? '');
if ($param_name !== '') {
$params[$param_name] = $param_value;
}
$add_content_links[] = [
'label' => $label,
'route_name' => $route_name,
'route_parameters' => $params,
'weight' => (int) ($row['weight'] ?? 0),
];
}
// Reordena por weight.
usort($add_content_links, fn($a, $b) => $a['weight'] <=> $b['weight']);
$config->set('add_content_links', $add_content_links);
// Salvar role_view_modes: apenas os valores marcados (filtrar 0). // Salvar role_view_modes: apenas os valores marcados (filtrar 0).
$role_view_modes_raw = $form_state->getValue('role_view_modes') ?? []; $role_view_modes_raw = $form_state->getValue('role_view_modes') ?? [];
$roles = \Drupal\user\Entity\Role::loadMultiple(); $roles = \Drupal\user\Entity\Role::loadMultiple();

View File

@@ -1,219 +0,0 @@
<?php
namespace Drupal\site_users\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\user\UserInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Bloco com informações básicas do usuário.
*
* Exibe automaticamente as informações do usuário da rota /user/{id}.
*
* @Block(
* id = "site_users_info_block",
* admin_label = @Translation("User Information"),
* category = @Translation("Site Users")
* )
*/
class UserInfoBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The current route match.
*
* @var \Drupal\Core\Routing\RouteMatchInterface
*/
protected $routeMatch;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a new UserInfoBlock instance.
*
* @param array $configuration
* The plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The current route match.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
RouteMatchInterface $route_match,
EntityTypeManagerInterface $entity_type_manager
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->routeMatch = $route_match;
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('current_route_match'),
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function build() {
$user = $this->getUserFromContext();
if (!$user instanceof UserInterface) {
return [];
}
// Obter foto padrão.
$default_photo = NULL;
$default_photo_url = NULL;
if (function_exists('site_users_get_default_photo')) {
$default_photo = site_users_get_default_photo($user);
if ($default_photo) {
// Usar o source field do media (forma padrão do Drupal).
$source_field = $default_photo->getSource()->getConfiguration()['source_field'];
if ($default_photo->hasField($source_field) && !$default_photo->get($source_field)->isEmpty()) {
$file = $default_photo->get($source_field)->entity;
if ($file) {
$default_photo_url = \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri());
}
}
}
}
// Coletar informações do usuário.
$user_info = [
'uid' => $user->id(),
'username' => $user->getDisplayName(),
'name' => $this->getFieldValue($user, 'field_user_name'),
'phone' => $this->getFieldValue($user, 'field_user_phone'),
'bio' => $this->getFieldValue($user, 'field_user_bio'),
'social_links' => $this->getSocialLinks($user),
'photo_url' => $default_photo_url,
'photo_alt' => $default_photo ? $default_photo->label() : '',
];
return [
'#theme' => 'site_users_info_block',
'#user_info' => $user_info,
'#user' => $user,
'#attached' => [
'library' => [
'site_users/user-info-block',
],
],
];
}
/**
* Obtém o usuário da rota atual.
*
* @return \Drupal\user\UserInterface|null
* O usuário ou NULL se não estiver em uma página de usuário.
*/
protected function getUserFromContext(): ?UserInterface {
// Obter da rota /user/{user}.
$user = $this->routeMatch->getParameter('user');
if ($user instanceof UserInterface) {
return $user;
}
// Se for apenas o ID, carregar o usuário.
if (is_numeric($user)) {
return $this->entityTypeManager->getStorage('user')->load($user);
}
return NULL;
}
/**
* Returns the social network links of a user.
*
* @param \Drupal\user\UserInterface $user
* The user entity.
*
* @return array
* Array of items with 'network' and 'url' keys.
*/
protected function getSocialLinks(UserInterface $user): array {
$links = [];
if (!$user->hasField('field_user_social_links') || $user->get('field_user_social_links')->isEmpty()) {
return $links;
}
foreach ($user->get('field_user_social_links') as $item) {
if (!$item->isEmpty()) {
$links[] = [
'network' => $item->network,
'url' => $item->url,
];
}
}
return $links;
}
/**
* Obtém o valor de um campo do usuário.
*
* @param \Drupal\user\UserInterface $user
* O usuário.
* @param string $field_name
* O nome do campo.
*
* @return string|null
* O valor do campo ou NULL.
*/
protected function getFieldValue(UserInterface $user, string $field_name): ?string {
if ($user->hasField($field_name) && !$user->get($field_name)->isEmpty()) {
return $user->get($field_name)->value;
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
$tags = parent::getCacheTags();
$user = $this->getUserFromContext();
if ($user instanceof UserInterface) {
$tags = Cache::mergeTags($tags, $user->getCacheTags());
}
return $tags;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return Cache::mergeContexts(parent::getCacheContexts(), ['route', 'user']);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Drupal\site_users\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
/**
* Formata o ID Lattes como link para o Currículo Lattes.
*
* URL gerada: https://lattes.cnpq.br/{id}
*
* @FieldFormatter(
* id = "site_users_lattes_link",
* label = @Translation("Link para o Currículo Lattes"),
* field_types = {"integer"}
* )
*/
#[FieldFormatter(
id: 'site_users_lattes_link',
label: new TranslatableMarkup('Link para o Currículo Lattes'),
field_types: ['integer'],
)]
class LattesLinkFormatter extends FormatterBase {
public static function isApplicable(FieldDefinitionInterface $field_definition): bool {
return $field_definition->getName() === 'field_user_id_lattes';
}
public function viewElements(FieldItemListInterface $items, $langcode): array {
$elements = [];
foreach ($items as $delta => $item) {
$id = (string) $item->value;
$elements[$delta] = [
'#type' => 'link',
'#title' => $id,
'#url' => Url::fromUri('https://lattes.cnpq.br/' . $id),
'#attributes' => ['target' => '_blank', 'rel' => 'noopener noreferrer'],
];
}
return $elements;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Drupal\site_users\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
/**
* Formata o MathSciNet ID como link para o perfil de autor no MathSciNet.
*
* URL gerada: https://mathscinet.ams.org/mathscinet/author?AuthorID={id}
*
* @FieldFormatter(
* id = "site_users_mathscinet_link",
* label = @Translation("Link para o perfil MathSciNet"),
* field_types = {"integer"}
* )
*/
#[FieldFormatter(
id: 'site_users_mathscinet_link',
label: new TranslatableMarkup('Link para o perfil MathSciNet'),
field_types: ['integer'],
)]
class MathSciNetLinkFormatter extends FormatterBase {
public static function isApplicable(FieldDefinitionInterface $field_definition): bool {
return $field_definition->getName() === 'field_user_mathscinetid';
}
public function viewElements(FieldItemListInterface $items, $langcode): array {
$elements = [];
foreach ($items as $delta => $item) {
$id = (string) $item->value;
$elements[$delta] = [
'#type' => 'link',
'#title' => $id,
'#url' => Url::fromUri('https://mathscinet.ams.org/mathscinet/author?AuthorID=' . $id),
'#attributes' => ['target' => '_blank', 'rel' => 'noopener noreferrer'],
];
}
return $elements;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Drupal\site_users\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
/**
* Formata o ORCID como link para o perfil em orcid.org.
*
* URL gerada: https://orcid.org/{orcid}
*
* @FieldFormatter(
* id = "site_users_orcid_link",
* label = @Translation("Link para o perfil ORCID"),
* field_types = {"string"}
* )
*/
#[FieldFormatter(
id: 'site_users_orcid_link',
label: new TranslatableMarkup('Link para o perfil ORCID'),
field_types: ['string'],
)]
class OrcidLinkFormatter extends FormatterBase {
public static function isApplicable(FieldDefinitionInterface $field_definition): bool {
return $field_definition->getName() === 'field_user_orcid';
}
public function viewElements(FieldItemListInterface $items, $langcode): array {
$elements = [];
foreach ($items as $delta => $item) {
$id = $item->value;
$elements[$delta] = [
'#type' => 'link',
'#title' => $id,
'#url' => Url::fromUri('https://orcid.org/' . $id),
'#attributes' => ['target' => '_blank', 'rel' => 'noopener noreferrer'],
];
}
return $elements;
}
}

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 [];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Drupal\site_users\Plugin\Menu;
use Drupal\Core\Menu\MenuLinkDefault;
/**
* Provides derived menu links for add-content actions in the account menu.
*
* @MenuLink(
* id = "site_users.add_content_child",
* deriver = "Drupal\site_users\Plugin\Menu\AddContentMenuLinkDeriver"
* )
*/
class AddContentMenuLink extends MenuLinkDefault {
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Drupal\site_users\Plugin\Menu;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Generates account menu "Adicionar" child links from site_users.settings.
*/
class AddContentMenuLinkDeriver extends DeriverBase implements ContainerDeriverInterface {
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
) {}
public static function create(ContainerInterface $container, $base_plugin_id): static {
return new static($container->get('config.factory'));
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition): array {
$this->derivatives = [];
$items = $this->configFactory
->get('site_users.settings')
->get('add_content_links') ?? [];
foreach ($items as $item) {
if (empty($item['route_name']) || empty($item['label'])) {
continue;
}
$id = $this->buildId($item);
$this->derivatives[$id] = $base_plugin_definition;
$this->derivatives[$id]['title'] = $item['label'];
$this->derivatives[$id]['route_name'] = $item['route_name'];
$this->derivatives[$id]['route_parameters'] = $item['route_parameters'] ?? [];
$this->derivatives[$id]['weight'] = (int) ($item['weight'] ?? 0);
$this->derivatives[$id]['menu_name'] = 'account';
$this->derivatives[$id]['parent'] = 'site_users.add_content';
}
return $this->derivatives;
}
/**
* Builds a stable derivative ID from the item's route and parameters.
*/
private function buildId(array $item): string {
$parts = [preg_replace('/[^a-z0-9]/', '_', strtolower($item['route_name']))];
foreach ($item['route_parameters'] ?? [] as $value) {
$parts[] = preg_replace('/[^a-z0-9]/', '_', strtolower((string) $value));
}
return implode('_', $parts);
}
}

View File

@@ -32,6 +32,11 @@ class LdapPhotoSyncService {
* changed since the last sync (same MD5), no file or media write occurs. * changed since the last sync (same MD5), no file or media write occurs.
*/ */
public function syncFromLdapEntry(UserInterface $account, Entry $ldapEntry): void { public function syncFromLdapEntry(UserInterface $account, Entry $ldapEntry): void {
// Usuário sem ID ainda não foi salvo — não é possível gerar URI única.
if (!$account->id()) {
return;
}
$config = $this->configFactory->get('site_users.settings'); $config = $this->configFactory->get('site_users.settings');
if (!$config->get('photos.ldap_sync_enabled')) { if (!$config->get('photos.ldap_sync_enabled')) {
@@ -48,6 +53,11 @@ class LdapPhotoSyncService {
return; return;
} }
$min_size = (int) ($config->get('photos.ldap_min_photo_size') ?? 10240);
if ($min_size > 0 && strlen($binary) < $min_size) {
return;
}
$extension = $this->detectExtension($binary); $extension = $this->detectExtension($binary);
if (!$extension) { if (!$extension) {
return; return;

View File

@@ -1,63 +0,0 @@
{#
/**
* @file
* Template for the user information block.
*
* Available variables:
* - user_info: Array with user information:
* - uid: User ID
* - username: Display name
* - name: Full name
* - phone: Phone number
* - bio: Biography
* - social_links: Array of social links, each with 'network' and 'url' keys
* - photo_url: Default photo URL
* - photo_alt: Photo alternative text
* - user: User entity.
*/
#}
<div class="site-user-info-block">
<div class="site-user-info-block__photo">
{% if user_info.photo_url %}
<img src="{{ user_info.photo_url }}" alt="{{ user_info.photo_alt }}" class="site-user-info-block__image" />
{% else %}
<div class="site-user-info-block__no-photo">
<span class="site-user-info-block__initials">
{{ user_info.name ? user_info.name|first|upper : user_info.username|first|upper }}
</span>
</div>
{% endif %}
</div>
<div class="site-user-info-block__details">
<h2 class="site-user-info-block__name">
{{ user_info.name ?: user_info.username }}
</h2>
{% if user_info.phone %}
<div class="site-user-info-block__phone">
<span class="site-user-info-block__label">{{ 'Phone:'|t }}</span>
<a href="tel:{{ user_info.phone }}">{{ user_info.phone }}</a>
</div>
{% endif %}
{% if user_info.bio %}
<div class="site-user-info-block__bio">
{{ user_info.bio }}
</div>
{% endif %}
{% if user_info.social_links %}
<div class="site-user-info-block__social-links">
{% for link in user_info.social_links %}
<a href="{{ link.url }}"
class="social-link social-link--{{ link.network }}"
target="_blank"
rel="noopener noreferrer">
{{ link.network }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,87 @@
/**
* Microsite theme — estilos para formulários de configuração do usuário.
*/
.microsite-form {
background: #fff;
border-radius: 6px;
padding: 2rem;
max-width: 600px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.microsite-form .form-item {
margin-bottom: 1.5rem;
}
.microsite-form label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
font-size: 0.95rem;
color: #333;
}
.microsite-form select,
.microsite-form input[type="text"],
.microsite-form input[type="email"],
.microsite-form textarea {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
color: #222;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
appearance: auto;
}
.microsite-form select:focus,
.microsite-form input:focus,
.microsite-form textarea:focus {
outline: none;
border-color: hsl(202, 79%, 50%);
box-shadow: 0 0 0 3px hsla(202, 79%, 50%, 0.2);
}
.microsite-form .form-item__description,
.microsite-form .description {
margin-top: 0.35rem;
font-size: 0.85rem;
color: #666;
}
.microsite-form .form-actions {
margin-top: 2rem;
padding-top: 1.25rem;
border-top: 1px solid #eee;
}
.microsite-form .button,
.microsite-form input[type="submit"] {
display: inline-block;
padding: 0.55rem 1.5rem;
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
color: #fff;
background: hsl(202, 79%, 50%);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.microsite-form .button:hover,
.microsite-form input[type="submit"]:hover {
background: hsl(202, 79%, 42%);
}
.microsite-form .button:focus,
.microsite-form input[type="submit"]:focus {
outline: none;
box-shadow: 0 0 0 3px hsla(202, 79%, 50%, 0.35);
}

View File

@@ -0,0 +1,556 @@
/**
* Microsite theme — estilos base.
* Ponto de partida minimalista. Personalize conforme o design do site.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
body.microsite {
margin: 0;
font-family: system-ui, sans-serif;
background: #f5f5f5;
color: #222;
}
/* Skip link */
.skip-link {
position: absolute;
top: -100%;
left: 0;
padding: 0.5rem 1rem;
background: #000;
color: #fff;
z-index: 9999;
}
.skip-link:focus {
top: 0;
}
/* Header ------------------------------------------------------------------- */
.microsite-header {
background: #1a1a2e;
color: #fff;
padding: 2rem 1.5rem;
}
.microsite-header__inner {
display: flex;
align-items: center;
gap: 1.5rem;
max-width: 900px;
margin: 0 auto;
}
.microsite-header__photo img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.microsite-header__name {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: 700;
}
.microsite-header__roles {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.microsite-header__role {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(255, 255, 255, 0.15);
padding: 0.2rem 0.6rem;
border-radius: 999px;
}
/* Navigation --------------------------------------------------------------- */
.microsite-nav {
background: #16213e;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.microsite-nav ul.tabs {
list-style: none;
margin: 0;
padding: 0 1.5rem;
display: flex;
max-width: 900px;
margin: 0 auto;
}
.microsite-nav ul.tabs li {
margin: 0;
}
.microsite-nav ul.tabs a {
display: block;
padding: 0.75rem 1.25rem;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
font-size: 0.9rem;
border-bottom: 3px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.microsite-nav ul.tabs a:hover,
.microsite-nav ul.tabs a:focus {
color: #fff;
}
.microsite-nav ul.tabs li.is-active a,
.microsite-nav ul.tabs a.is-active {
color: #fff;
border-bottom-color: #e94560;
}
/* Highlighted -------------------------------------------------------------- */
.microsite-highlighted {
max-width: 900px;
margin: 1.5rem auto 0;
padding: 0 1.5rem;
}
/* Main + Sidebar ----------------------------------------------------------- */
.microsite-main-wrapper {
max-width: 900px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.microsite-main-wrapper--has-sidebar {
display: grid;
grid-template-columns: 1fr 280px;
gap: 2rem;
max-width: 1200px;
}
.microsite-main {
min-width: 0;
}
.microsite-sidebar {
min-width: 0;
}
.microsite-messages {
margin-bottom: 1.5rem;
}
/* Microsite Header Block --------------------------------------------------- */
/*
* Bloco .msite-header-block: foto circular, nome (h1), meta (roles +
* departamento), biografia e lista de contatos.
* Assume fundo escuro herdado do .microsite-header.
*/
.msite-header-block {
display: flex;
align-items: flex-start;
gap: 1.75rem;
max-width: 1500px;
margin: 0 auto;
padding: 2rem 1.5rem;
color: #fff;
}
/* --- Foto ----------------------------------------------------------------- */
.msite-header-block__photo-wrap {
flex-shrink: 0;
}
.msite-header-block__photo {
display: block;
width: 220px;
height: 280px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.35);
}
/* Fallback de iniciais quando não há foto */
.msite-header-block__photo--initials {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
font-size: 4rem;
font-weight: 700;
color: #fff;
user-select: none;
}
/* --- Informações ---------------------------------------------------------- */
.msite-header-block__info {
flex: 1;
min-width: 0;
}
.msite-header-block__name {
margin: 0 0 0.3rem;
font-size: 1.8rem;
font-weight: 700;
line-height: 1.2;
color: #fff;
}
.msite-header-block__meta {
margin: 0 0 0.75rem;
font-size: 0.95rem;
font-style: italic;
color: rgba(255, 255, 255, 0.75);
}
.msite-header-block__bio {
margin-bottom: 0.75rem;
font-size: 0.9rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.85);
}
/* --- Contatos ------------------------------------------------------------- */
.msite-header-block__contact {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.8);
}
.msite-header-block__contact-label {
font-weight: 600;
}
.msite-header-block__contact a {
color: hsl(202, 79%, 70%);
text-decoration: none;
}
.msite-header-block__contact a:hover {
text-decoration: underline;
}
/* --- Links acadêmicos (Lattes, ORCID, MathSciNet) ------------------------- */
.msite-header-block__academic-links {
display: flex;
gap: 0.6rem;
margin-top: 0.75rem;
}
.msite-header-block__academic-link svg {
display: block;
width: 32px;
height: 32px;
transition: opacity 0.15s, transform 0.15s;
}
.msite-header-block__academic-link--homepage svg {
fill: rgba(255, 255, 255, 0.85);
}
.msite-header-block__academic-link:hover svg {
opacity: 0.85;
transform: scale(1.1);
}
/* Top Bar ------------------------------------------------------------------ */
/*
* Barra estreita no topo da página, antes do header.
* Destinada ao menu do usuário (login, conta, logout).
* Alinhada à direita, fundo escuro, texto claro.
*/
.microsite-top-bar {
background-color: hsl(201, 15%, 20%);
padding-inline: 1.5rem;
}
.microsite-top-bar .block-menu {
display: flex;
justify-content: flex-end;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
}
.microsite-top-bar ul.menu {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
.microsite-top-bar li.menu__item:not(:last-child) {
margin-inline-end: 18px;
}
.microsite-top-bar li.menu__item {
position: relative;
}
.microsite-top-bar ul.menu ul.menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
flex-direction: column;
background-color: hsl(201, 15%, 15%);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
z-index: 100;
padding: 4px 0;
}
.microsite-top-bar li.menu__item:hover > ul.menu,
.microsite-top-bar li.menu__item:focus-within > ul.menu {
display: flex;
}
.microsite-top-bar ul.menu ul.menu li.menu__item {
margin-inline-end: 0;
white-space: nowrap;
}
.microsite-top-bar ul.menu ul.menu a.menu__link {
padding: 6px 16px;
width: 100%;
box-sizing: border-box;
}
.microsite-top-bar ul.menu ul.menu a.menu__link:hover {
background-color: hsl(201, 15%, 25%);
}
.microsite-top-bar a.menu__link {
position: relative;
display: inline-flex;
align-items: center;
padding-block: 7px;
text-decoration: none;
color: hsl(201, 15%, 88%);
}
.microsite-top-bar a.menu__link:hover {
color: #fff;
}
.microsite-top-bar a.menu__link.is-active {
color: hsl(202, 79%, 70%);
}
/* Navigation --------------------------------------------------------------- */
/*
* Estilo baseado no nav-secondary do Olivero.
* Variáveis Olivero traduzidas para valores fixos (o tema base é stable9):
* --color--primary-50 → hsl(202, 79%, 50%) ≈ #1a9fd4
* --color--gray-5 → hsl(201, 15%, 5%) ≈ #0b0e0f (texto)
* --sp → 18px
* --sp0-5 → 9px
* --sp2 → 36px
* --font-size-s → 14px
*/
.microsite-nav {
background-color: #fff;
border-bottom: 3px solid hsl(202, 79%, 50%);
padding-inline: 1.5rem;
}
/* Bloco de menu padrão dentro da região nav */
.microsite-nav .block-menu {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
}
/* Lista principal: horizontal, sem marcadores */
.microsite-nav ul.menu {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
/* Itens: espaço entre eles */
.microsite-nav li.menu__item:not(:last-child) {
margin-inline-end: 36px;
}
/* Subitens: não exibidos (menu plano) */
.microsite-nav ul.menu ul.menu {
display: none;
}
/* Links */
.microsite-nav a.menu__link {
position: relative;
display: inline-flex;
align-items: center;
padding-block: 12px;
text-decoration: none;
color: hsl(201, 15%, 5%);
}
/* Underline animado no hover (padrão Olivero) */
.microsite-nav a.menu__link::after {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 0;
content: "";
transition: opacity 0.2s, transform 0.2s;
transform: translateY(5px);
opacity: 0;
border-top: solid 2px currentColor;
}
.microsite-nav a.menu__link:hover::after,
.microsite-nav a.menu__link.is-active::after {
transform: translateY(0);
opacity: 0.8;
}
/* Link ativo: cor primária */
.microsite-nav a.menu__link.is-active {
color: hsl(202, 79%, 50%);
}
/* Social Bar --------------------------------------------------------------- */
/*
* Mobile: barra horizontal compacta.
* Desktop (≥ 1200px): coluna lateral fixa à esquerda, 90px de largura,
* conteúdo girado 90° (estilo Olivero Social Bar).
*/
.social-bar__inner {
position: relative;
padding: 0.5rem 1rem;
}
.social-bar__inner .rotate > * {
margin-bottom: 0.5rem;
}
@media (min-width: 1200px) {
/* Layout flex: barra social à esquerda, conteúdo principal à direita */
.microsite-layout--has-social {
display: flex;
align-items: flex-start;
}
.microsite-content-area {
flex: 1;
min-width: 0;
}
/* Coluna .social-bar no fluxo (placeholder de 90px) */
.social-bar {
flex-shrink: 0;
width: 90px;
background-color: #f3f3f3;
align-self: stretch;
}
/* Wrapper interno: posicionamento relativo normal; .is-fixed o fixa */
.social-bar__inner {
position: relative;
width: 90px;
padding: 2.5rem 0;
}
.social-bar__inner.is-fixed {
position: fixed;
top: 3rem;
left: 0;
height: calc(100vh - 3rem);
}
/* Conteúdo girado em 90° */
.social-bar__inner .rotate {
position: absolute;
left: 50%;
display: flex;
flex-direction: row-reverse;
width: 100vh;
transform: rotate(-90deg) translateX(-100%);
transform-origin: left;
}
@supports (width: max-content) {
.social-bar__inner .rotate {
width: max-content;
}
}
.social-bar__inner .rotate > * {
display: flex;
align-items: center;
margin-bottom: 0;
}
.social-bar__inner .rotate > *:not(:first-child) {
margin-right: 2rem;
}
}
/* Footer ------------------------------------------------------------------- */
.microsite-footer {
background: #16213e;
color: rgba(255, 255, 255, 0.7);
padding: 2rem 1.5rem;
margin-top: 2rem;
}
.microsite-footer a {
color: rgba(255, 255, 255, 0.85);
}
/* Responsividade básica ---------------------------------------------------- */
@media (max-width: 768px) {
.microsite-main-wrapper--has-sidebar {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.microsite-header__inner {
flex-direction: column;
text-align: center;
}
.microsite-header__roles {
justify-content: center;
}
}

View File

@@ -0,0 +1,35 @@
/**
* @file
* Barra social — comportamento de fixação ao scroll (estilo Olivero).
*
* Aplica a classe .is-fixed ao elemento .social-bar__inner quando o cabeçalho
* do micro-site sai do viewport, no breakpoint desktop (≥ 1200px).
*/
((Drupal) => {
const socialBarInner = document.querySelector(
'[data-drupal-selector="social-bar-inner"]',
);
if (!socialBarInner) return;
const navBreakpoint = window.matchMedia('(min-width: 1200px)');
function updateFixed(entries) {
if (!navBreakpoint.matches) {
socialBarInner.classList.remove('is-fixed');
return;
}
entries.forEach((entry) => {
socialBarInner.classList.toggle('is-fixed', entry.intersectionRatio < 1);
});
}
const header = document.querySelector('.microsite-header');
if (header) {
const observer = new IntersectionObserver(updateFixed, {
threshold: [0.999, 1],
});
observer.observe(header);
}
})(Drupal);

View File

@@ -0,0 +1,23 @@
name: 'Site Users Microsite Theme'
type: theme
description: 'Tema para micro-sites pessoais de usuários. Sem navegação principal do site.'
core_version_requirement: ^10 || ^11
# Use o tema principal do site aqui se quiser herdar fontes e variáveis CSS.
# stable9 é o tema base mínimo do core (sem estilos próprios).
base theme: stable9
libraries:
- site_users_microsite_theme/global
regions:
top_bar: Top Bar
header: Header
nav: Navigation
highlighted: Highlighted
tabs: Tabs
messages: Messages
content_above: Content Above
content: Content
content_below: Content Below
sidebar: Sidebar
social: Social
footer: Footer

View File

@@ -0,0 +1,15 @@
global:
css:
theme:
css/microsite.css: {}
js:
js/social-bar.js: {}
dependencies:
- core/drupal
form:
css:
theme:
css/microsite-form.css: {}
dependencies:
- site_users_microsite_theme/global

View File

@@ -0,0 +1,108 @@
{#
/**
* @file
* Template de página do micro-site pessoal.
*
* Renderiza apenas as regiões — a estrutura HTML completa (html, head, body)
* é responsabilidade do html.html.twig herdado do tema base (stable9).
*
* Variáveis adicionadas pelo site_users_microsite.module:
* - microsite_user: entidade UserInterface do usuário dono do micro-site.
* - microsite_user_name: nome de exibição do usuário (string).
* - microsite_user_roles: array de roles do usuário (exceto 'authenticated').
* - microsite_user_photo: render array da foto padrão (view mode 'thumbnail').
*/
#}
<div class="microsite-layout{% if page.social %} microsite-layout--has-social{% endif %}">
{% if page.social %}
<div class="social-bar">
{{ page.social }}
</div>
{% endif %}
<div class="microsite-content-area">
{% if page.top_bar %}
<div class="microsite-top-bar" role="navigation" aria-label="{{ 'User account menu'|t }}">
{{ page.top_bar }}
</div>
{% endif %}
{# Header: blocos configurados no admin OU header padrão gerado com dados do usuário. #}
{% if page.header %}
<header class="microsite-header" role="banner">
{{ page.header }}
</header>
{% elseif microsite_user is defined %}
<header class="microsite-header microsite-header--default" role="banner">
<div class="microsite-header__inner">
{% if microsite_user_photo is defined %}
<div class="microsite-header__photo">
{{ microsite_user_photo }}
</div>
{% endif %}
<div class="microsite-header__info">
<h1 class="microsite-header__name">{{ microsite_user_name }}</h1>
{% if microsite_user_roles %}
<ul class="microsite-header__roles">
{% for role in microsite_user_roles %}
<li class="microsite-header__role">{{ role }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
</header>
{% endif %}
{% if page.nav %}
<nav class="microsite-nav" role="navigation" aria-label="{{ 'User page menu'|t }}">
{{ page.nav }}
</nav>
{% endif %}
{% if page.highlighted %}
<div class="microsite-highlighted">
{{ page.highlighted }}
</div>
{% endif %}
<nav class="microsite-nav" aria-label="{{ 'User site navigation'|t }}">
{{ page.tabs }}
</nav>
{% if page.messages %}
<div class="microsite-messages">
{{ page.messages }}
</div>
{% endif %}
<div class="microsite-main-wrapper{% if page.sidebar %} microsite-main-wrapper--has-sidebar{% endif %}">
<main id="main-content" class="microsite-main" role="main">
{{ page.content_above }}
{{ page.content }}
</main>
{% if page.sidebar %}
<aside class="microsite-sidebar">
{{ page.sidebar }}
</aside>
{% endif %}
</div>
{% if page.content_below %}
<div class="microsite-content-below">
{{ page.content_below }}
</div>
{% endif %}
{% if page.footer %}
<footer class="microsite-footer">
{{ page.footer }}
</footer>
{% endif %}
</div>{# fim .microsite-content-area #}
</div>

View File

@@ -0,0 +1,20 @@
{#
/**
* @file
* Template para a região Social — barra lateral vertical (estilo Olivero).
*
* Available variables:
* - content: The content for this region, typically blocks.
* - attributes: HTML attributes for the region <div>.
* - region: The name of the region variable as defined in the theme's
* .info.yml file.
*
* @see \Drupal\Core\Theme\ThemePreprocess::preprocessRegion()
*/
#}
<div class="social-bar__inner" data-drupal-selector="social-bar-inner">
<div class="rotate">
{{ content }}
</div>
</div>

View File

@@ -0,0 +1,39 @@
{#
/**
* @file
* Template de menu do microsite.
*
* Gera as classes usadas pelo CSS da região nav (.menu, .menu__item,
* .menu__link), seguindo o mesmo padrão de classes do Olivero.
*/
#}
{% import _self as menus %}
{% set attributes = attributes.addClass('menu') %}
{{ menus.menu_links(items, attributes, 0) }}
{% macro menu_links(items, attributes, menu_level) %}
{% import _self as menus %}
{% if items %}
<ul{{ attributes.addClass('menu--level-' ~ (menu_level + 1)) }}>
{% set attributes = attributes.removeClass('menu--level-' ~ (menu_level + 1)) %}
{% for item in items %}
<li{{ item.attributes.addClass([
'menu__item',
'menu__item--level-' ~ (menu_level + 1),
item.in_active_trail ? 'menu__item--active-trail',
]) }}>
{{ link(item.title, item.url, {
'class': [
'menu__link',
'menu__link--level-' ~ (menu_level + 1),
item.in_active_trail ? 'menu__link--active-trail',
]
}) }}
{% if item.below %}
{{ menus.menu_links(item.below, attributes, menu_level + 1) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}

View File

@@ -171,3 +171,14 @@ msgstr "Este usuário não tornou seu perfil público."
# Template # Template
msgid "Phone:" msgid "Phone:"
msgstr "Telefone:" msgstr "Telefone:"
# Field labels - field_user_mathscinetid
msgid "MathSciNet ID"
msgstr "ID MathSciNet"
msgid "MathSciNet identifier of the researcher."
msgstr "Identificador MathSciNet do pesquisador."
# Install/update - field_user_mathscinetid
msgid "MathSciNet ID field created successfully."
msgstr "Campo ID MathSciNet criado com sucesso."