From 1a8db0710106aceadce2e97a5e249aad5d9d0e6a Mon Sep 17 00:00:00 2001 From: jyhein <124268211+jyhein@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:53:06 +0300 Subject: [PATCH] Simultaneously Displaying Multilingual Metadata on the Article Landing Page --- pages/article/ArticleHandler.php | 29 +++ plugins/themes/default/DefaultThemePlugin.php | 24 +++ plugins/themes/default/js/main.js | 194 ++++++++++++++++++ plugins/themes/default/locale/en/locale.po | 9 + .../styles/objects/article_details.less | 128 ++++++++++++ .../frontend/objects/article_details.tpl | 138 +++++++++++-- 6 files changed, 506 insertions(+), 16 deletions(-) diff --git a/pages/article/ArticleHandler.php b/pages/article/ArticleHandler.php index a40ad52ec32..e608afc9d08 100644 --- a/pages/article/ArticleHandler.php +++ b/pages/article/ArticleHandler.php @@ -24,6 +24,7 @@ use APP\observers\events\UsageEvent; use APP\payment\ojs\OJSCompletedPaymentDAO; use APP\payment\ojs\OJSPaymentManager; +use APP\publication\Publication; use APP\security\authorization\OjsJournalMustPublishPolicy; use APP\submission\Submission; use APP\template\TemplateManager; @@ -357,6 +358,12 @@ public function view($args, $request) $templateMgr->assign('purchaseArticleEnabled', true); } + $templateMgr->assign('pubLocaleData', $this->getMultilingualMetadataOpts( + $publication, + $templateMgr->getTemplateVars('currentLocale'), + $templateMgr->getTemplateVars('activeTheme')->getOption('showMultilingualMetadata') ?: [], + )); + if (!Hook::call('ArticleHandler::view', [&$request, &$issue, &$article, $publication])) { $templateMgr->display('frontend/pages/article.tpl'); event(new UsageEvent(Application::ASSOC_TYPE_SUBMISSION, $context, $article, null, null, $this->issue)); @@ -616,4 +623,26 @@ public function userCanViewGalley($request, $articleId, $galleyId = null) } return true; } + + /** + * Multilingual publication metadata for template: + * showMultilingualMetadataOpts - Show metadata in other languages: title (+ subtitle), keywords, abstract, etc. + */ + protected function getMultilingualMetadataOpts(Publication $publication, string $currentUILocale, array $showMultilingualMetadataOpts): array + { + $langNames = collect($publication->getLanguageNames()) + ->sortKeys(); + $langs = $langNames->keys(); + return [ + 'opts' => array_flip($showMultilingualMetadataOpts), + 'localeNames' => $langNames, + 'langTags' => $langNames->map(fn ($_, $l) => preg_replace(['/@.+$/', '/_/'], ['', '-'], $l))->toArray() /* remove @ and text after */, + 'localeOrder' => collect($publication->getLocalePrecedence()) + ->intersect($langs) /* remove locales not in publication's languages */ + ->concat($langs) + ->unique() + ->values() + ->toArray(), + ]; + } } diff --git a/plugins/themes/default/DefaultThemePlugin.php b/plugins/themes/default/DefaultThemePlugin.php index 25b0568deac..b52dc9fbdf5 100644 --- a/plugins/themes/default/DefaultThemePlugin.php +++ b/plugins/themes/default/DefaultThemePlugin.php @@ -124,6 +124,30 @@ public function init() 'default' => 'none', ]); + $this->addOption('showMultilingualMetadata', 'FieldOptions', [ + 'label' => __('plugins.themes.default.option.metadata.label'), + 'description' => __('plugins.themes.default.option.metadata.description'), + 'options' => [ + [ + 'value' => 'title', + 'label' => __('submission.title'), + ], + [ + 'value' => 'keywords', + 'label' => __('common.keywords'), + ], + [ + 'value' => 'abstract', + 'label' => __('common.abstract'), + ], + [ + 'value' => 'author', + 'label' => __('default.groups.name.author'), + ], + ], + 'default' => [], + ]); + // Load primary stylesheet $this->addStyle('stylesheet', 'styles/index.less'); diff --git a/plugins/themes/default/js/main.js b/plugins/themes/default/js/main.js index 0775189b43f..641a52365ad 100644 --- a/plugins/themes/default/js/main.js +++ b/plugins/themes/default/js/main.js @@ -114,3 +114,197 @@ }); })(jQuery); + +/** + * Create language buttons to show multilingual metadata + * [data-pkp-locales]: Publication's locales in order + * [data-pkp-switcher-text]: Texts for the switchers to control + * [data-pkp-switcher-target]: Switchers' containers + */ +(() => { + function createButtonSwitcher(textsObj, originalLocaleOrder, metadataFieldName, selectedLocale) { + // Get all locales for the switcher from the texts + const textsElsLocales = textsObj.els.reduce((locales, textEls) => { + textEls.forEach((el) => { + locales[el.getAttribute('data-pkp-locale')] = el.getAttribute('data-pkp-locale-name'); + }); + return locales; + }, {}); + + // Create containers + const spanContainer = document.createElement('span'); + [ + ['class', `switcher-buttons-${metadataFieldName}`], + ].forEach((attr) => spanContainer.setAttribute(...attr)); + + const spanButtons = document.createElement('span'); + const spanButtonsId = `switcher-buttons-${metadataFieldName}`; + [ + ['id', spanButtonsId], + ].forEach((attr) => spanButtons.setAttribute(...attr)); + + // Create, sort to alphabetical order, and append buttons + originalLocaleOrder + .map((elLocale) => { + if (!textsElsLocales[elLocale]) { + return null; + } + if (!selectedLocale.value) { + selectedLocale.value = elLocale; + } + + const isSelectedLocale = elLocale === selectedLocale.value; + const button = document.createElement('button'); + [ + ['data-pkp-locale', elLocale], + ['data-pkp-switcher-button', metadataFieldName], + ['class', `pkpBadge pkpBadge--button collapse-button${isSelectedLocale ? ' selected-button show-button' : ''}`], + ['type', 'button'], + ['aria-controls', isSelectedLocale ? spanButtonsId : textsObj.ids.join(' ')], + ...isSelectedLocale + ? [ + ['aria-expanded', false], + ] + : [], + ].forEach((attr) => button.setAttribute(...attr)); + button.textContent = textsElsLocales[elLocale]; + + return button; + }) + .filter((btn) => btn) + .sort((a, b) => a.getAttribute('data-pkp-locale').localeCompare(b.getAttribute('data-pkp-locale'))) + .forEach((btn) => spanButtons.appendChild(btn)); + + // If only one button, set it disabled + if (spanButtons.children.length === 1) { + spanButtons.children[0].disabled = true; + } + + spanContainer.appendChild(spanButtons); + + return spanContainer; + } + + /** + * Show or hide switcher's target texts + * If selected locale doesn't match any, all texts are hidden + */ + function showText(selectedLocale, textsEls) { + textsEls.forEach((textsEl) => { + textsEl.forEach((textEl) => { + const elLocale = textEl.getAttribute('data-pkp-locale'); + if (elLocale === selectedLocale.value) { + textEl.classList.add('show-text'); + } else { + textEl.classList.remove('show-text'); + } + }); + }); + } + + /** + * Change/update buttons' aria-attributes + */ + function switchButtonAria(btnTarget, buttons) { + let btnTargetOldAriaControls = btnTarget.getAttribute('aria-controls'); + let btnPrevSelectedLangAriaControls = null; + buttons.forEach((btn) => { + // Previously selected langauge button + if (btn.getAttribute('aria-expanded')) { + btnPrevSelectedLangAriaControls = btn.getAttribute('aria-controls'); + btn.removeAttribute('aria-expanded'); + btn.setAttribute('aria-controls', btnTargetOldAriaControls); + } + }); + btnTarget.setAttribute('aria-expanded', true); + btnTarget.setAttribute('aria-controls', btnPrevSelectedLangAriaControls); + } + + function setButtonSwitcher(textsObj, switcherTargetEl, metadataFieldName, originalLocaleOrder) { + // Currently selected language for buttons and texts + const selectedLocale = {value: null}; + const buttonSwitcherEl = createButtonSwitcher(textsObj, originalLocaleOrder, metadataFieldName, selectedLocale); + + // Sync buttons and shown texts + showText(selectedLocale, textsObj.els); + + const buttons = buttonSwitcherEl.querySelectorAll('button'); + + // Add listeners if more than one button + if (buttons.length > 1) { + // Selected language shows/hides other switcher buttons, and otherwise switches language and shows text + buttonSwitcherEl.addEventListener('click', (evt) => { + const btnTarget = evt.target; + if (btnTarget.type === 'button') { + if (btnTarget.getAttribute('data-pkp-locale') === selectedLocale.value) { + buttons.forEach((btn) => { + if (btn.getAttribute('data-pkp-locale') !== selectedLocale.value) { + btn.classList.toggle('show-button'); + } + }); + btnTarget.setAttribute('aria-expanded', true); + } else { + selectedLocale.value = btnTarget.getAttribute('data-pkp-locale'); + switchButtonAria(btnTarget, buttons); + buttons.forEach((btn) => { + if (btn.getAttribute('data-pkp-locale') === selectedLocale.value) { + btn.classList.add('selected-button'); + } else { + btn.classList.remove('selected-button'); + } + }); + showText(selectedLocale, textsObj.els); + } + } + }); + // Hide switcher (except selected language) buttons when it loses focus + buttonSwitcherEl.addEventListener('focusout', (evt) => { + if (!evt.relatedTarget || evt.relatedTarget.getAttribute('data-pkp-switcher-button') !== metadataFieldName) { + buttons.forEach((btn) => { + if (btn.getAttribute('data-pkp-locale') !== selectedLocale.value) { + btn.classList.remove('show-button'); + } else { + btn.setAttribute('aria-expanded', false); + } + }); + } + }); + } + + // Append and show switcher + switcherTargetEl.append(buttonSwitcherEl); + switcherTargetEl.classList.remove('collapse-switcher'); + } + + /** + * Get all multilingual texts and ids for the switchers + */ + function getSwitcherTexts() { + const textsObj = {}; + document.querySelectorAll('[data-pkp-switcher-text]').forEach((textsEl) => { + const key = textsEl.getAttribute('data-pkp-switcher-text'); + if (!textsObj[key]) { + textsObj[key] = {ids: [], els: []}; + } + textsObj[key].ids.push(textsEl.id); + textsObj[key].els.push([...textsEl.querySelectorAll('[data-pkp-locale]')]); + }); + return textsObj; + } + + (() => { + const originalLocaleOrder = document.querySelector('[data-pkp-locales]')?.getAttribute('data-pkp-locales').split(','); + const switcherTargetEls = document.querySelectorAll('[data-pkp-switcher-target]'); + if (!originalLocaleOrder || !switcherTargetEls.length) { + return; + } + const switcherTextsObj = getSwitcherTexts(); + // Get target elements for switchers and create them + switcherTargetEls.forEach((switcherTargetEl) => { + const metadataFieldName = switcherTargetEl.getAttribute('data-pkp-switcher-target'); + if (switcherTextsObj[metadataFieldName]) { + setButtonSwitcher(switcherTextsObj[metadataFieldName], switcherTargetEl, metadataFieldName, originalLocaleOrder); + } + }); + })(); +})(); diff --git a/plugins/themes/default/locale/en/locale.po b/plugins/themes/default/locale/en/locale.po index c783e630ff5..6e31ce8e6e6 100644 --- a/plugins/themes/default/locale/en/locale.po +++ b/plugins/themes/default/locale/en/locale.po @@ -98,3 +98,12 @@ msgstr "Next slide" msgid "plugins.themes.default.prevSlide" msgstr "Previous slide" + +msgid "plugins.themes.default.option.metadata.label" +msgstr "Show article metadata on the article landing page" + +msgid "plugins.themes.default.option.metadata.description" +msgstr "Select the article metadata to show in other languages." + +msgid "plugins.themes.default.ariaDescription.languageSwitcher" +msgstr "Selects a language to show the metadata in." diff --git a/plugins/themes/default/styles/objects/article_details.less b/plugins/themes/default/styles/objects/article_details.less index d8c032b0a0d..9007fd78538 100644 --- a/plugins/themes/default/styles/objects/article_details.less +++ b/plugins/themes/default/styles/objects/article_details.less @@ -281,6 +281,134 @@ } } + .page_title, + .subtitle, + ul.authors .name, + .abstract h2 { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + } + + [data-pkp-switcher-target], + [class^=switcher-buttons], + [class^=switcher-buttons] > span { + display: inline-flex; + } + + [data-pkp-switcher-target=title] { + &, & + .row { + margin-top: calc(@triple / 2); + } + } + + [data-pkp-locale] p:first-of-type { + margin-top: 0; + } + + [class^=switcher-buttons] { + & > span { + flex-wrap: wrap; + align-content: flex-start; + gap: 0.2rem; + } + } + + .pkpBadge { + display: inline-flex; + padding: 0.25em 1em; + font-size: @font-tiny; + font-weight: @normal; + line-height: 1.5em; + border: 1px solid @bg-border-color-light; + border-radius: 1.2em; + color: @text; + } + + .pkpBadge--button { + background: inherit; + text-decoration: none; + cursor: pointer; + + &:hover { + border-color: @text; + outline: 0; + } + &:disabled, + &:disabled:hover { + color: #fff; + background: @bg-dark; + border-color: @bg-dark; + cursor: not-allowed; + } + } + + .collapse-switcher { + display: none; + } + + .collapse-button { + &:not(.show-button) { + display: none; + } + } + + .collapse-text { + &:not(.show-text) { + display: none; + } + } + + .selected-button { + font-weight: @bold; + } + + .show-button, + .show-text { + display: inline-flex; + animation: fadeIn 0.7s ease-in-out; + + @keyframes fadeIn { + 0% { + display: none; + opacity: 0; + } + + 1% { + display: inline-flex; + opacity: 0; + } + + 100% { + opacity: 1; + } + } + } + + .page_title, + .subtitle { + // Keep the space if no metadata + & .collapse-text:first-of-type:not(.show-text) { + visibility: hidden; + display: inline-flex; + height: @line-lead; + width: 0; + overflow: hidden; + } + // Remove the gap + & .show-text { + order: -1; + } + } + + // Show inline instead of inline-flex + .keywords { + & .show-text { + display: inline; + } + } + @media(min-width: @screen-phone) { .entry_details { diff --git a/templates/frontend/objects/article_details.tpl b/templates/frontend/objects/article_details.tpl index 0c5b51e4b0a..50bd7775e29 100755 --- a/templates/frontend/objects/article_details.tpl +++ b/templates/frontend/objects/article_details.tpl @@ -64,6 +64,7 @@ * @uses $licenseUrl string URL to license. Only assigned if license should be * included with published submissions. * @uses $ccLicenseBadge string An image and text with details about the license + * @uses $pubLocaleData Array of e.g. publication's locales and metadata field names to show in multiple languages * * @hook Templates::Article::Main [] * @hook Templates::Article::Details::Reference [] @@ -72,7 +73,10 @@ {if !$heading} {assign var="heading" value="h3"} {/if} -
+
{* Indicate if this is only a preview *} {if $publication->getData('status') !== PKP\submission\PKPSubmission::STATUS_PUBLISHED} @@ -91,15 +95,54 @@ {/if} -

- {$publication->getLocalizedTitle(null, 'html')|strip_unsafe_html} +

+ {$publicationTitles=$publication->getTitles('html')} + {$first=true} + {foreach from=$pubLocaleData.localeOrder item=$localeKey} + {if !isset($publicationTitles[$localeKey])}{continue}{/if} + + {$publicationTitles[$localeKey]|strip_unsafe_html} + + {if !isset($pubLocaleData.opts.title)}{break}{/if} + {$first=false} + {/foreach}

- {if $publication->getLocalizedData('subtitle')} -

- {$publication->getLocalizedSubTitle(null, 'html')|strip_unsafe_html} + {if $publication->getSubTitles('html')} +

+ {$publicationSubtitles=$publication->getSubTitles('html')} + {$first=true} + {foreach from=$pubLocaleData.localeOrder item=$localeKey} + {if !isset($publicationSubtitles[$localeKey])}{continue}{/if} + + {$publicationSubtitles[$localeKey]|strip_unsafe_html} + + {if !isset($pubLocaleData.opts.title)}{break}{/if} + {$first=false} + {/foreach}

{/if} + {if isset($pubLocaleData.opts.title)} + + {/if}
@@ -112,10 +155,29 @@
  • {$author->getFullName()|escape} + {if isset($pubLocaleData.opts.author)} + + {/if} - {if $author->getLocalizedData('affiliation')} + {if $author->getData('affiliation')} - {$author->getLocalizedData('affiliation')|escape} + + {$authorAffiliations=$author->getData('affiliation')} + {$first=true} + {foreach from=$pubLocaleData.localeOrder item=$localeKey} + {if !isset($authorAffiliations[$localeKey])}{continue}{/if} + + {$authorAffiliations[$localeKey]|strip_unsafe_html} + + {if !isset($pubLocaleData.opts.author)}{break}{/if} + {$first=false} + {/foreach} + {if $author->getData('rorId')} {$rorIdIcon} {/if} @@ -162,25 +224,69 @@ {* Keywords *} - {if !empty($publication->getLocalizedData('keywords'))} + {if $publication->getData('keywords')}

    {capture assign=translatedKeywords}{translate key="article.subject"}{/capture} {translate key="semicolon" label=$translatedKeywords}

    - - {foreach name="keywords" from=$publication->getLocalizedData('keywords') item="keyword"} - {$keyword|escape}{if !$smarty.foreach.keywords.last}{translate key="common.commaListSeparator"}{/if} + + {$publicationKeywords=$publication->getData('keywords')} + {$first=true} + {foreach from=$pubLocaleData.localeOrder item=$localeKey} + {if !isset($publicationKeywords[$localeKey])}{continue}{/if} + + {foreach from=$publicationKeywords[$localeKey] item="keyword"} + {$keyword|escape}{if !$keyword@last}{translate key="common.commaListSeparator"}{/if} {/foreach} + + {if !isset($pubLocaleData.opts.keywords)}{break}{/if} + {$first=false} + {/foreach} + {if isset($pubLocaleData.opts.keywords)} + + {/if}
    {/if} {* Abstract *} - {if $publication->getLocalizedData('abstract')} -
    -

    {translate key="article.abstract"}

    - {$publication->getLocalizedData('abstract')|strip_unsafe_html} + {if $publication->getData('abstract')} +
    +

    + {translate key="article.abstract"} + {if isset($pubLocaleData.opts.abstract)} + + {/if} +

    + {$publicationAbstracts=$publication->getData('abstract')} + {$first=true} + {foreach from=$pubLocaleData.localeOrder item=$localeKey} + {if !isset($publicationAbstracts[$localeKey])}{continue}{/if} + + {$publicationAbstracts[$localeKey]|strip_unsafe_html} + + {if !isset($pubLocaleData.opts.abstract)}{break}{/if} + {$first=false} + {/foreach}
    {/if}