From b8fe499bb06797a405f0cce6ed2355fcabf67724 Mon Sep 17 00:00:00 2001 From: Christos Sidiropoulos Date: Wed, 13 May 2026 15:04:55 +0000 Subject: [PATCH 1/2] [FEATURE] Add basic default viewer Signed-off-by: Christos Sidiropoulos --- .../BasicViewer/constants.typoscript | 6 + .../TypoScript/BasicViewer/setup.typoscript | 85 +++ Documentation/User/Index.rst | 5 + Documentation/User/ManualViewerSetup.rst | 43 ++ .../Partials/Navigation/PageSelect.html | 24 +- .../Partials/Toolbox/ImageDownloadTool.html | 6 +- .../Partials/Toolbox/PdfDownloadTool.html | 8 +- .../Private/Templates/Bootstrap/Landing.html | 89 +++ .../Private/Templates/Bootstrap/Viewer.html | 107 ++++ .../Public/Stylesheets/default-viewer.css | 596 ++++++++++++++++++ 10 files changed, 951 insertions(+), 18 deletions(-) create mode 100644 Configuration/TypoScript/BasicViewer/constants.typoscript create mode 100644 Configuration/TypoScript/BasicViewer/setup.typoscript create mode 100644 Documentation/User/ManualViewerSetup.rst create mode 100644 Resources/Private/Templates/Bootstrap/Landing.html create mode 100644 Resources/Private/Templates/Bootstrap/Viewer.html create mode 100644 Resources/Public/Stylesheets/default-viewer.css diff --git a/Configuration/TypoScript/BasicViewer/constants.typoscript b/Configuration/TypoScript/BasicViewer/constants.typoscript new file mode 100644 index 0000000000..7519e36d54 --- /dev/null +++ b/Configuration/TypoScript/BasicViewer/constants.typoscript @@ -0,0 +1,6 @@ +plugin.tx_dlf { + basicViewer { + rootPid = + viewerPid = + } +} diff --git a/Configuration/TypoScript/BasicViewer/setup.typoscript b/Configuration/TypoScript/BasicViewer/setup.typoscript new file mode 100644 index 0000000000..534cba504b --- /dev/null +++ b/Configuration/TypoScript/BasicViewer/setup.typoscript @@ -0,0 +1,85 @@ +page = PAGE +page { + typeNum = 0 + meta.viewport = width=device-width, initial-scale=1 + includeCSS.dlfBasicViewer = EXT:dlf/Resources/Public/Stylesheets/default-viewer.css + + 10 = FLUIDTEMPLATE + 10 { + templateRootPaths.10 = EXT:dlf/Resources/Private/Templates/Bootstrap/ + file = EXT:dlf/Resources/Private/Templates/Bootstrap/Landing.html + + variables { + rootPid = TEXT + rootPid.value = {$plugin.tx_dlf.basicViewer.rootPid} + + viewerPid = TEXT + viewerPid.value = {$plugin.tx_dlf.basicViewer.viewerPid} + + documentId = TEXT + documentId.data = GP:tx_dlf|id + } + } +} + +plugin.tx_dlf_navigation = EXTBASEPLUGIN +plugin.tx_dlf_navigation { + extensionName = Dlf + pluginName = Navigation + settings { + pageStep = 10 + features = pagesBackward,pageSelect,pagesForward + } +} + +plugin.tx_dlf_metadata = EXTBASEPLUGIN +plugin.tx_dlf_metadata { + extensionName = Dlf + pluginName = Metadata + settings { + linkTitle = 0 + getTitle = 0 + showFull = 1 + rootline = 1 + separator = # + } +} + +plugin.tx_dlf_pageview = EXTBASEPLUGIN +plugin.tx_dlf_pageview { + extensionName = Dlf + pluginName = PageView + settings { + features = + elementId = tx-dlf-map + useInternalProxy = 1 + } +} + +plugin.tx_dlf_tableofcontents = EXTBASEPLUGIN +plugin.tx_dlf_tableofcontents { + extensionName = Dlf + pluginName = TableOfContents + settings { + targetPid = {$plugin.tx_dlf.basicViewer.viewerPid} + } +} + +plugin.tx_dlf_toolbox = EXTBASEPLUGIN +plugin.tx_dlf_toolbox { + extensionName = Dlf + pluginName = Toolbox + settings { + tools = rotationTool,zoomTool,imageDownloadTool,pdfDownloadTool,fulltextTool,fulltextDownloadTool + showAsList = 0 + activateFullTextInitially = 0 + fullTextScrollElement = #tx-dlf-toolbox-fulltext-selection + } +} + +[getTSFE() && getTSFE().id == {$plugin.tx_dlf.basicViewer.viewerPid}] +page { + bodyTag = + 10.file = EXT:dlf/Resources/Private/Templates/Bootstrap/Viewer.html +} +[END] diff --git a/Documentation/User/Index.rst b/Documentation/User/Index.rst index 159171d0d3..71772e5bc5 100644 --- a/Documentation/User/Index.rst +++ b/Documentation/User/Index.rst @@ -16,6 +16,11 @@ User Manual :local: :depth: 2 +.. toctree:: + :hidden: + + ManualViewerSetup + .. _indexing_documents: Indexing Documents diff --git a/Documentation/User/ManualViewerSetup.rst b/Documentation/User/ManualViewerSetup.rst new file mode 100644 index 0000000000..c68f267de1 --- /dev/null +++ b/Documentation/User/ManualViewerSetup.rst @@ -0,0 +1,43 @@ +################### +Manual Viewer Setup +################### + +This document describes the verified manual backend setup for a standalone viewer page and configuration folder without using the root setup CLI command. + +Working Sequence +================ + +1. Create a root page (``Create a regular page > Edit page properties > Behavior > Use as root page``) +2. Create or edit the TYPO3 site configuration for that root page in ``Site Management > Sites``. +3. Create these children below the root page (the naming may differ): + + * ``Viewer`` with page type ``Standard`` + * ``Kitodo Configuration`` with page type ``Folder`` + +4. Create a TypoScript template record (``sys_template``) on the root page: + + * ``Site Management > TypoScript > Edit TypoScript Record (Top Dropdownmenu) > [select the root page] > Create a root TypoScript record`` + +5. Edit the whole TypoScript record. + + Under the tab ``General``: + + * set the title + * clear any prefilled content in the ``Setup`` field + * set the ``Constants`` field to the required values: + + .. code-block:: typoscript + + plugin.tx_dlf.persistence.storagePid = + plugin.tx_dlf.basicViewer.rootPid = + plugin.tx_dlf.basicViewer.viewerPid = + + Under the tab ``Advanced Options``: + + * enable ``Rootlevel`` + * include these TypoScript sets: + + * ``Basic Configuration`` + * ``Install Basic Viewer`` + +6. Apply tenant defaults to the configuration folder through the backend new-tenant module. diff --git a/Resources/Private/Partials/Navigation/PageSelect.html b/Resources/Private/Partials/Navigation/PageSelect.html index c4272336ad..cbf595ec4e 100644 --- a/Resources/Private/Partials/Navigation/PageSelect.html +++ b/Resources/Private/Partials/Navigation/PageSelect.html @@ -12,28 +12,30 @@
  • - +
    - + - + - + - + - - - + +
  • diff --git a/Resources/Private/Partials/Toolbox/ImageDownloadTool.html b/Resources/Private/Partials/Toolbox/ImageDownloadTool.html index 671ae43ca8..a9ac927809 100644 --- a/Resources/Private/Partials/Toolbox/ImageDownloadTool.html +++ b/Resources/Private/Partials/Toolbox/ImageDownloadTool.html @@ -15,17 +15,17 @@ - + {imageDownload.0.mimetypeLabel} - + {imageDownload.0.mimetypeLabel} - + {imageDownload.1.mimetypeLabel} diff --git a/Resources/Private/Partials/Toolbox/PdfDownloadTool.html b/Resources/Private/Partials/Toolbox/PdfDownloadTool.html index 5b409a24e0..4696d72e94 100644 --- a/Resources/Private/Partials/Toolbox/PdfDownloadTool.html +++ b/Resources/Private/Partials/Toolbox/PdfDownloadTool.html @@ -16,22 +16,22 @@ - (PDF) + (PDF) - (PDF) + (PDF) - (PDF) + (PDF) - (PDF) + (PDF) diff --git a/Resources/Private/Templates/Bootstrap/Landing.html b/Resources/Private/Templates/Bootstrap/Landing.html new file mode 100644 index 0000000000..fc9c186e36 --- /dev/null +++ b/Resources/Private/Templates/Bootstrap/Landing.html @@ -0,0 +1,89 @@ + +
    +
    +
    +

    Kitodo Presentation

    +

    New viewer instance

    +

    + This installation already includes a working viewer page and a prepared configuration folder. +

    + +
    + +
    + + +
    +
    +
    + +
    +
    +

    Examples

    +

    Examples of supported content types

    +
    + +
    +
    +

    Classic documents

    +

    Prints, letters, newspapers

    +

    Representative METS/OAI-PMH document for traditional digitized materials such as prints, letters, newspapers and other classic sources.

    +

    Examples:

    + + "Der furnembsten, notwendigsten, der gantzen Architectur ..." + + + "Prima [- Undecima] pars librorum divi Aurelii Augustini ..." + + + "Historia Baetica" + +
    + +
    +

    Musical sources

    +

    Scores and bar-by-bar navigation

    +

    Support for digitized musical sources in MEI format is stable. Musical scores are rendered with Verovio and can be navigated bar by bar.

    + Examples coming soon +
    + +
    +

    3D objects

    +

    Embedded model viewers

    +

    Support for 3D objects is now an official feature. Kitodo.Presentation integrates Google's open source model-viewer and also allows other viewers to be used instead.

    + Examples coming soon +
    +
    +
    + +
    +
    +

    Formats

    +

    Supported input formats

    +
    +
      +
    • OAI-PMH endpoints delivering compatible records
    • +
    • METS documents
    • +
    • OCR and full text via ALTO and TEI
    • +
    • MEI-based musical sources
    • +
    • 3D object workflows through configured embedded viewers
    • +
    +

    + Open documents on the viewer page by passing the source URL in tx_dlf[id]. +

    +
    +
    +
    + diff --git a/Resources/Private/Templates/Bootstrap/Viewer.html b/Resources/Private/Templates/Bootstrap/Viewer.html new file mode 100644 index 0000000000..297ca440a2 --- /dev/null +++ b/Resources/Private/Templates/Bootstrap/Viewer.html @@ -0,0 +1,107 @@ + +
    +
    + + +
    +
    +
    +
    +
    +

    Kitodo Presentation

    +

    Viewer

    +
    +
    + Back +
    +
    + +
    + + +
    +
    + + + + + + +
    +
    +

    No Document

    +

    No document selected

    +

    Pass a document URL in tx_dlf[id] to open a METS or IIIF document.

    +
    +
    +
    +
    +
    +
    +
    + + +
    + diff --git a/Resources/Public/Stylesheets/default-viewer.css b/Resources/Public/Stylesheets/default-viewer.css new file mode 100644 index 0000000000..19cd9eef47 --- /dev/null +++ b/Resources/Public/Stylesheets/default-viewer.css @@ -0,0 +1,596 @@ +body.tx-dlf-default-viewer-page { + margin: 0; + min-height: 100vh; +} + +.tx-dlf-default-viewer-shell { + --default-ink: #18212c; + --default-link: #1a4d8f; + --default-link-hover: #0f539f; + --default-accent: #213345; + --default-surface: rgba(255, 255, 255, 0.94); + --default-border: rgba(24, 33, 44, 0.1); + --default-shadow: 0 10px 24px rgba(7, 14, 21, 0.14); + min-height: 100vh; + font-family: Arial, sans-serif; + color: var(--default-ink); + background: whitesmoke; +} + +.tx-dlf-default-viewer-viewframe { + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + min-height: 100vh; +} + + +.tx-dlf-default-viewer-landing { + min-height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.94)), + linear-gradient(120deg, rgba(26, 77, 143, 0.08), transparent 35%), + whitesmoke; +} + +.tx-dlf-default-viewer-welcome { + width: min(1080px, calc(100% - 48px)); + margin: 48px auto; + padding: 40px 44px 56px; + border: 1px solid var(--default-border); + border-radius: 20px; + background: var(--default-surface); + box-shadow: var(--default-shadow); +} + +.tx-dlf-default-viewer-welcome-hero, +.tx-dlf-default-viewer-welcome-section { + margin-bottom: 40px; + padding: 0; +} + +.tx-dlf-default-viewer-welcome-hero h1, +.tx-dlf-default-viewer-welcome-header h2, +.tx-dlf-default-viewer-example-card h3 { + margin: 0; +} + +.tx-dlf-default-viewer-welcome-lead { + max-width: 760px; + margin: 18px 0 0; + font-size: 18px; + line-height: 1.65; +} + +.tx-dlf-default-viewer-welcome-header { + margin-bottom: 18px; +} + +.tx-dlf-default-viewer-welcome-hero { + padding-bottom: 34px; + border-bottom: 1px solid var(--default-border); +} + + +.tx-dlf-default-viewer-example-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.tx-dlf-default-viewer-example-card { + display: grid; + gap: 12px; + align-content: start; + padding: 20px 18px; + border: 1px solid var(--default-border); + border-radius: 16px; + background: white; +} + +.tx-dlf-default-viewer-example-card p, +.tx-dlf-default-viewer-welcome-section p { + margin: 0; + line-height: 1.6; +} + +.tx-dlf-default-viewer-link { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--default-link); + font-weight: 700; + text-decoration: none; +} + +.tx-dlf-default-viewer-link:hover { + color: var(--default-link-hover); +} + +.tx-dlf-default-viewer-link-muted { + color: slategray; +} + +.tx-dlf-default-viewer-link-muted:hover { + color: slategray; +} + +.tx-dlf-default-viewer-format-list { + margin: 0 0 18px; + padding-left: 20px; +} + +.tx-dlf-default-viewer-format-list li { + margin-bottom: 10px; + line-height: 1.6; +} + + +.tx-dlf-default-viewer-input-form { + display: grid; + gap: 14px; +} + +.tx-dlf-default-viewer-input-label { + font-weight: 700; +} + +.tx-dlf-default-viewer-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.tx-dlf-default-viewer-input { + min-width: 0; + min-height: 46px; + padding: 0 14px; + border: 1px solid var(--default-border); + border-radius: 12px; + background: white; + color: var(--default-ink); + font: inherit; +} + +.tx-dlf-default-viewer-input:focus { + outline: 2px solid rgba(26, 77, 143, 0.22); + outline-offset: 1px; +} + +.tx-dlf-default-viewer-welcome-section-compact { + max-width: 760px; + margin-bottom: 0; + padding-top: 10px; + border-top: 1px solid var(--default-border); +} + +.tx-dlf-default-viewer-control-bar { + display: grid; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); + gap: 1px; + min-height: 100vh; + border-right: 1px solid var(--default-border); + background: rgba(24, 33, 44, 0.14); +} + +.tx-dlf-default-viewer-control-panel, +.tx-dlf-default-viewer-card { + border: 1px solid var(--default-border); + background: var(--default-surface); +} + +.tx-dlf-default-viewer-control-panel { + min-width: 0; + padding: 22px 20px; + overflow: auto; +} + +.tx-dlf-default-viewer-control-header { + margin-bottom: 16px; +} + +.tx-dlf-default-viewer-main { + min-width: 0; +} + +.tx-dlf-default-viewer-document-view { + position: relative; + min-height: 100vh; + overflow: hidden; +} + +.tx-dlf-default-viewer-empty-state { + position: absolute; + inset: 0; + z-index: 5; + display: grid; + place-items: center; + padding: 24px; +} + +.tx-dlf-default-viewer-empty-notice { + width: min(460px, 100%); + padding: 24px 28px; + border: 1px solid var(--default-border); + border-radius: 18px; + background: var(--default-surface); + box-shadow: var(--default-shadow); + text-align: center; +} + +.tx-dlf-default-viewer-empty-notice h2, +.tx-dlf-default-viewer-empty-notice p { + margin-top: 0; +} + +.tx-dlf-default-viewer-empty-notice p:last-child { + margin-bottom: 0; +} + +.tx-dlf-default-viewer-document-functions { + position: absolute; + inset: 0 0 auto; + z-index: 20; + display: grid; + gap: 10px; + padding: 14px 18px; + pointer-events: none; +} + +.tx-dlf-default-viewer-provider, +.tx-dlf-default-viewer-submenu { + pointer-events: auto; + border: 1px solid var(--default-border); + border-radius: 10px; + background: var(--default-surface); + box-shadow: var(--default-shadow); +} + +.tx-dlf-default-viewer-provider { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 12px 16px; +} + +.tx-dlf-default-viewer-provider h1, +.tx-dlf-default-viewer-control-header h2, +.tx-dlf-default-viewer-card h2 { + margin: 0; +} + +.tx-dlf-default-viewer-provider h1 { + font-size: 26px; + line-height: 1; +} + +.tx-dlf-default-viewer-provider-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.tx-dlf-default-viewer-kicker, +.tx-dlf-default-viewer-panel-kicker { + margin: 0 0 4px; + color: slategray; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tx-dlf-default-viewer-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 0 14px; + border: 1px solid var(--default-accent); + border-radius: 999px; + background: var(--default-accent); + color: white; + font-size: 13px; + font-weight: 700; + text-decoration: none; +} + +.tx-dlf-default-viewer-button-secondary { + background: white; + color: var(--default-accent); + border-color: rgba(33, 51, 69, 0.2); +} + +.tx-dlf-default-viewer-submenu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 10px 12px; + color: var(--default-accent); +} + +.tx-dlf-default-viewer-submenu ul, +.tx-dlf-default-viewer-submenu li { + margin: 0; + padding: 0; + list-style: none; +} + +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-backward, +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-forward, +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages, +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages form { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.tx-dlf-default-viewer-submenu a, +.tx-dlf-default-viewer-submenu span, +.tx-dlf-default-viewer-submenu label, +.tx-dlf-default-viewer-submenu select { + color: inherit; + font-size: 13px; + text-decoration: none; +} + + +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-backward > div, +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-forward > div, +.tx-dlf-default-viewer-submenu li { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--default-border); + border-radius: 7px; + background: var(--default-surface); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.tx-dlf-default-viewer-submenu a:hover, +.tx-dlf-default-viewer-submenu li.tx-dlf-tools-fullscreen a.active { + color: var(--default-link-hover); +} + +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-backward span, +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-forward span { + opacity: 0.46; +} + +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages { + gap: 10px; + padding: 6px 10px; + background: var(--default-surface); +} + +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages label { + font-weight: 700; +} + +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages select { + min-width: 110px; + min-height: 32px; + padding: 0 8px; + border: 1px solid var(--default-border); + border-radius: 6px; + background: white; +} + +.tx-dlf-default-viewer-submenu li[class^="tx-dlf-tools-"] span, +.tx-dlf-default-viewer-submenu li[class^="tx-dlf-tools-"] a { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.tx-dlf-default-viewer-document-view .tx-dlf-map { + position: absolute; + inset: 0; +} + +#tx-dlf-annotationselection, +#tx-dlf-toolbox-fulltext-selection { + border-radius: 10px; + background: var(--default-surface); + box-shadow: var(--default-shadow); +} + +#tx-dlf-annotationselection { + position: absolute; + top: 110px; + right: 24px; + z-index: 21; + max-width: 320px; + padding: 12px 14px; +} + +#tx-dlf-annotationselection:empty, +#tx-dlf-toolbox-fulltext-selection:empty { + display: none; +} + +.tx-dlf-toolbox-fulltext-container { + position: absolute; + right: 24px; + bottom: 24px; + left: 24px; + z-index: 16; + pointer-events: none; +} + +#tx-dlf-toolbox-fulltext-selection { + max-width: min(640px, 100%); + max-height: 28vh; + overflow: auto; + padding: 14px 16px; + font-size: 14px; + line-height: 1.55; + pointer-events: auto; +} + +#tx-dlf-toolbox-fulltext-selection .textline, +#tx-dlf-toolbox-fulltext-selection .sp { + display: inline; +} + +#tx-dlf-toolbox-fulltext-selection .sp { + white-space: pre; +} + +#tx-dlf-toolbox-fulltext-selection .highlight { + background: khaki; +} + +#tx-dlf-toolbox-fulltext-selection .string, +#tx-dlf-toolbox-fulltext-selection .hyp { + color: inherit; +} + +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents, +.tx-dlf-default-viewer-control-panel .tx-dlf-metadata-titledata { + font-size: 14px; +} + +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents ul { + margin: 0; + padding-left: 18px; +} + +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents li { + margin-bottom: 8px; +} + +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents a { + color: var(--default-link); + text-decoration: none; +} + +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents a:hover { + text-decoration: underline; +} + +.tx-dlf-metadata-titledata { + margin: 0; +} + +.tx-dlf-metadata-titledata dt { + margin: 0 0 4px; + color: #425264; + font-weight: 700; +} + +.tx-dlf-metadata-titledata dd { + margin: 0 0 14px; +} + +.tx-dlf-default-viewer-card { + max-width: 760px; + margin: 12vh auto 0; + padding: 28px; + border-radius: 18px; + box-shadow: 0 24px 60px rgba(9, 16, 24, 0.14); +} + +.tx-dlf-default-viewer-actions { + margin: 24px 0 0; +} + +.tx-dlf-default-viewer-example { + margin-bottom: 0; + word-break: break-all; +} + +body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-viewframe { + grid-template-columns: 1fr; +} + +body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-control-bar { + display: none; +} + +body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-document-functions { + padding: 12px; +} + +@media (max-width: 980px) { + .tx-dlf-default-viewer-welcome { + width: min(100% - 32px, 1080px); + margin: 24px auto; + padding: 32px 28px 40px; + } + + .tx-dlf-default-viewer-example-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1180px) { + .tx-dlf-default-viewer-viewframe { + grid-template-columns: 1fr; + } + + .tx-dlf-default-viewer-main { + order: 1; + } + + .tx-dlf-default-viewer-control-bar { + order: 2; + min-height: auto; + grid-template-rows: none; + grid-template-columns: 1fr; + border-top: 1px solid var(--default-border); + border-right: 0; + } +} + +@media (max-width: 720px) { + .tx-dlf-default-viewer-welcome { + width: min(100% - 24px, 1080px); + margin: 12px auto; + padding: 24px 18px 28px; + } + + .tx-dlf-default-viewer-welcome-lead { + font-size: 16px; + } + + .tx-dlf-default-viewer-input-row { + grid-template-columns: 1fr; + } + + .tx-dlf-default-viewer-input-row .tx-dlf-default-viewer-button { + width: 100%; + } + + .tx-dlf-default-viewer-document-functions { + padding: 12px; + } + + .tx-dlf-default-viewer-provider { + flex-direction: column; + align-items: flex-start; + } + + .tx-dlf-default-viewer-submenu { + gap: 8px; + } + + .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages, + .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages form { + width: 100%; + } + + .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages select { + min-width: 0; + flex: 1 1 auto; + } + + .tx-dlf-toolbox-fulltext-container { + right: 12px; + bottom: 12px; + left: 12px; + } +} From 3d99b6d6b14093cce01e9e159fd0a78b7bb0e162 Mon Sep 17 00:00:00 2001 From: Christos Sidiropoulos Date: Wed, 13 May 2026 15:06:45 +0000 Subject: [PATCH 2/2] [FEATURE] Add pagetree setup via CLI and backend Signed-off-by: Christos Sidiropoulos --- Classes/Command/BootstrapSetupCommand.php | 68 +++ .../Backend/NewTenantController.php | 44 +- Classes/Service/BootstrapRootSetupService.php | 558 ++++++++++++++++++ Configuration/Backend/Modules.php | 2 +- Configuration/Services.yaml | 6 + Configuration/TCA/Overrides/sys_template.php | 6 + Documentation/User/BootstrapViewerSetup.rst | 84 +++ Documentation/User/Index.rst | 1 + .../Private/Data/BootstrapSiteConfig.yaml | 26 + Resources/Private/Language/de.locallang.xlf | 20 + Resources/Private/Language/locallang.xlf | 15 + .../Templates/Backend/NewTenant/Root.html | 33 ++ .../JavaScript/Backend/new-tenant-root.js | 21 + .../Public/Stylesheets/default-viewer.css | 454 +++++--------- 14 files changed, 1020 insertions(+), 318 deletions(-) create mode 100644 Classes/Command/BootstrapSetupCommand.php create mode 100644 Classes/Service/BootstrapRootSetupService.php create mode 100644 Documentation/User/BootstrapViewerSetup.rst create mode 100644 Resources/Private/Data/BootstrapSiteConfig.yaml create mode 100644 Resources/Private/Templates/Backend/NewTenant/Root.html create mode 100644 Resources/Public/JavaScript/Backend/new-tenant-root.js diff --git a/Classes/Command/BootstrapSetupCommand.php b/Classes/Command/BootstrapSetupCommand.php new file mode 100644 index 0000000000..8029a4601e --- /dev/null +++ b/Classes/Command/BootstrapSetupCommand.php @@ -0,0 +1,68 @@ +setDescription('Create the root page tree and viewer setup manually.'); + $this->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Custom site identifier.'); + $this->addOption('base', null, InputOption::VALUE_REQUIRED, 'Custom site base path, for example /my-instance/.'); + $this->addOption('root-title', null, InputOption::VALUE_REQUIRED, 'Custom root page title.'); + $this->addOption('root-slug', null, InputOption::VALUE_REQUIRED, 'Custom root page slug.'); + $this->addOption('viewer-slug', null, InputOption::VALUE_REQUIRED, 'Custom viewer page slug.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Page setup'); + + try { + $result = $this->bootstrapRootSetupService->runSetup( + [ + 'identifier' => $input->getOption('identifier'), + 'base' => $input->getOption('base'), + 'rootTitle' => $input->getOption('root-title'), + 'rootSlug' => $input->getOption('root-slug'), + 'viewerSlug' => $input->getOption('viewer-slug'), + ] + ); + } catch (Throwable $exception) { + $io->error($exception->getMessage()); + return Command::FAILURE; + } + + $io->definitionList( + ['Site identifier' => $result['siteIdentifier']], + ['Site base' => $result['siteBase']], + ['Root page' => (string) $result['rootPageId']], + ['Viewer page' => (string) $result['viewerPageId']], + ['Configuration page' => (string) $result['configurationPageId']], + ['Template record' => (string) $result['templateId']], + ['Solr core' => 'not implemented yet'], + ); + $io->success('Default page setup completed.'); + + return Command::SUCCESS; + } +} diff --git a/Classes/Controller/Backend/NewTenantController.php b/Classes/Controller/Backend/NewTenantController.php index 8459268242..f2a37f0cb5 100644 --- a/Classes/Controller/Backend/NewTenantController.php +++ b/Classes/Controller/Backend/NewTenantController.php @@ -23,6 +23,7 @@ use Kitodo\Dlf\Domain\Repository\MetadataRepository; use Kitodo\Dlf\Domain\Repository\SolrCoreRepository; use Kitodo\Dlf\Domain\Repository\StructureRepository; +use Kitodo\Dlf\Service\BootstrapRootSetupService; use Psr\Http\Message\ResponseInterface; use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; use TYPO3\CMS\Backend\Utility\BackendUtility; @@ -139,18 +140,29 @@ public function injectSolrCoreRepository(SolrCoreRepository $solrCoreRepository) $this->solrCoreRepository = $solrCoreRepository; } + /** + * @access protected + */ + protected BootstrapRootSetupService $bootstrapRootSetupService; + + /** + * @access public + */ + public function injectBootstrapRootSetupService(BootstrapRootSetupService $bootstrapRootSetupService): void + { + $this->bootstrapRootSetupService = $bootstrapRootSetupService; + } + /** * Returns a response object with either the given html string or the current rendered view as content. * * @access protected * - * @param bool $isError whether to render the non-error or error template - * * @param mixed[] $extraData extra view data used to render the template (in addition to $viewData of AbstractController) * * @return ResponseInterface the response */ - protected function templateResponse(bool $isError, array $extraData): ResponseInterface + protected function templateResponse(string $template, array $extraData): ResponseInterface { $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); $messageQueue = $flashMessageService->getMessageQueueByIdentifier(); @@ -160,7 +172,6 @@ protected function templateResponse(bool $isError, array $extraData): ResponseIn $moduleTemplate->assignMultiple($this->viewData); $moduleTemplate->assignMultiple($extraData); $moduleTemplate->setFlashMessageQueue($messageQueue); - $template = $isError ? 'Backend/NewTenant/Error' : 'Backend/NewTenant/Index'; return $moduleTemplate->renderResponse($template); } @@ -195,6 +206,23 @@ protected function initializeAction(): void } + /** + * Action creating a default page tree from the virtual root page. + * + * @access public + */ + public function createPagesAction(): ResponseInterface + { + if ($this->pid !== 0) { + return $this->redirect('error'); + } + + $this->bootstrapRootSetupService->runSetup(); + + return $this->redirect('index', null, null, ['refreshPageTree' => 1]); + } + + /** * Action adding formats records * @@ -423,6 +451,10 @@ public function indexAction(): ResponseInterface { $recordInfos = []; + if ($this->pid === 0) { + return $this->templateResponse('Backend/NewTenant/Root', ['refreshPageTree' => (bool) ($this->request->getQueryParams()['refreshPageTree'] ?? false)]); + } + $this->pageInfo = BackendUtility::readPageAccess($this->pid, $GLOBALS['BE_USER']->getPagePermsClause(1)) ?: []; if (!isset($this->pageInfo['doktype']) || $this->pageInfo['doktype'] != 254) { @@ -445,7 +477,7 @@ public function indexAction(): ResponseInterface $viewData = ['recordInfos' => $recordInfos]; - return $this->templateResponse(false, $viewData); + return $this->templateResponse('Backend/NewTenant/Index', $viewData); } /** @@ -457,7 +489,7 @@ public function indexAction(): ResponseInterface */ public function errorAction(): ResponseInterface { - return $this->templateResponse(true, []); + return $this->templateResponse('Backend/NewTenant/Error', []); } /** diff --git a/Classes/Service/BootstrapRootSetupService.php b/Classes/Service/BootstrapRootSetupService.php new file mode 100644 index 0000000000..adaba2ee34 --- /dev/null +++ b/Classes/Service/BootstrapRootSetupService.php @@ -0,0 +1,558 @@ +buildSetupContext($options); + + $rootPageId = $this->createRootPage($context); + $this->writeSiteConfiguration($context, $rootPageId); + + $configurationPageId = $this->createPage($rootPageId, self::CONFIGURATION_PAGE_TITLE, ['doktype' => 254, 'sorting' => 256]); + $viewerPageId = $this->createPage($rootPageId, self::VIEWER_PAGE_TITLE, ['slug' => $context['viewerPageSlug'],'sorting' => 512]); + + $templateId = $this->ensureTemplate($rootPageId); + $this->updateTemplate( + $templateId, + $rootPageId, + $viewerPageId, + $configurationPageId, + ); + $this->cacheManager->flushCaches(); + + return [ + 'siteIdentifier' => $context['siteIdentifier'], + 'siteBase' => $context['siteBase'], + 'rootPageId' => $rootPageId, + 'viewerPageId' => $viewerPageId, + 'configurationPageId' => $configurationPageId, + 'templateId' => $templateId, + ]; + } + + /** + * Determines the next unique bootstrap site identifier, titles and slugs. + * + * @param array{identifier?:mixed,base?:mixed,rootTitle?:mixed,rootSlug?:mixed,viewerSlug?:mixed} $options + * @return array{siteIdentifier:string,siteBase:string,rootPageTitle:string,rootPageSlug:string,viewerPageSlug:string} + * @throws RuntimeException + */ + private function buildSetupContext(array $options): array + { + $nextIndex = $this->determineNextGroupIndex(); + $customIdentifier = $this->normalizeIdentifierOption($options['identifier'] ?? null); + $customBase = $this->normalizeBaseOption($options['base'] ?? null); + $customRootTitle = $this->normalizeTextOption($options['rootTitle'] ?? null); + $customRootSlug = $this->normalizeSlugOption($options['rootSlug'] ?? null, 'root-slug'); + $customViewerSlug = $this->normalizeSlugOption($options['viewerSlug'] ?? null, 'viewer-slug'); + + $siteIdentifier = $customIdentifier ?? ($nextIndex === 1 ? self::SITE_IDENTIFIER_PREFIX : self::SITE_IDENTIFIER_PREFIX . '-' . $nextIndex); + $defaultBase = ($customIdentifier === null && $nextIndex === 1 && !$this->siteBaseExists('/')) ? '/' : '/' . $siteIdentifier . '/'; + $siteBase = $customBase ?? $defaultBase; + $rootPageTitle = $customRootTitle ?? ($nextIndex === 1 ? self::ROOT_PAGE_TITLE_PREFIX : self::ROOT_PAGE_TITLE_PREFIX . ' ' . $nextIndex); + $rootPageSlug = $customRootSlug ?? '/'; + $viewerPageSlug = $customViewerSlug ?? '/viewer'; + + $this->assertSiteIdentifierAvailable($siteIdentifier); + $this->assertRootTitleAvailable($rootPageTitle); + if ($this->siteBaseExists($siteBase)) { + throw new RuntimeException('The site base "' . $siteBase . '" is already in use.'); + } + $this->assertBaseCompatibleWithSlugs($siteBase, $rootPageSlug, $viewerPageSlug); + + return [ + 'siteIdentifier' => $siteIdentifier, + 'siteBase' => $siteBase, + 'rootPageTitle' => $rootPageTitle, + 'rootPageSlug' => $rootPageSlug, + 'viewerPageSlug' => $viewerPageSlug, + ]; + } + + /** + * Finds the next free numeric index for bootstrap groups by inspecting existing sites and root pages. + * + * @return int The next available index, starting from 1. If no existing groups are found, returns 1. + */ + private function determineNextGroupIndex(): int + { + $maxIndex = 0; + foreach ($this->siteConfiguration->getAllExistingSites() as $site) { + if (!$site instanceof Site) { + continue; + } + + $maxIndex = max($maxIndex, $this->extractBootstrapIndex($site->getIdentifier(), self::SITE_IDENTIFIER_PREFIX, '-')); + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $rows = $queryBuilder + ->select('title') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchFirstColumn(); + + foreach ($rows as $title) { + if (!is_string($title)) { + continue; + } + + $maxIndex = max($maxIndex, $this->extractBootstrapIndex($title, self::ROOT_PAGE_TITLE_PREFIX, ' ')); + } + + return $maxIndex + 1; + } + + /** + * Extracts the bootstrap group index from a generated site identifier or root page title. + * + * @param string $value + * @param string $prefix + * @param string $separator + * @return int + */ + private function extractBootstrapIndex(string $value, string $prefix, string $separator): int + { + if ($value === $prefix) { + return 1; + } + + $pattern = '/^' . preg_quote($prefix, '/') . preg_quote($separator, '/') . '(\d+)$/'; + if (preg_match($pattern, $value, $matches) === 1) { + return (int) $matches[1]; + } + + return 0; + } + + /** + * Creates a fresh bootstrap root page for the next group. + * + * @param array{rootPageTitle:string,rootPageSlug:string} $context + * @return int The uid of the created root page + */ + private function createRootPage(array $context): int + { + return $this->insertRow( + 'pages', + [ + 'pid' => 0, + 'tstamp' => time(), + 'crdate' => time(), + 'deleted' => 0, + 'hidden' => 0, + 'doktype' => 1, + 'title' => $context['rootPageTitle'], + 'slug' => $context['rootPageSlug'], + 'is_siteroot' => 1, + 'sorting' => $this->nextSortingForPid(0), + ] + ); + } + + /** + * Creates a child page below a freshly created bootstrap root page. + * + * @param int $pid + * @param string $title + * @param array $data + * @return int + */ + private function createPage(int $pid, string $title, array $data): int + { + $pageData = array_merge( + [ + 'pid' => $pid, + 'tstamp' => time(), + 'crdate' => time(), + 'deleted' => 0, + 'hidden' => 0, + 'doktype' => 1, + 'title' => $title, + ], + $data + ); + + return $this->insertRow('pages', $pageData); + } + + /** + * Writes a unique bootstrap site configuration for the freshly created root page. + * + * @param array{siteIdentifier:string,siteBase:string} $context + * @param int $rootPageId + * @return void + * @throws RuntimeException + */ + private function writeSiteConfiguration(array $context, int $rootPageId): void + { + $templatePath = GeneralUtility::getFileAbsFileName(self::SITE_CONFIGURATION_TEMPLATE); + $configuration = Yaml::parseFile($templatePath); + if (!is_array($configuration)) { + throw new RuntimeException('Bootstrap site template is invalid.'); + } + + $configuration['base'] = $context['siteBase']; + $configuration['rootPageId'] = $rootPageId; + + $siteDirectory = Environment::getConfigPath() . '/sites/' . $context['siteIdentifier']; + GeneralUtility::mkdir_deep($siteDirectory); + if (!GeneralUtility::writeFile($siteDirectory . '/config.yaml', Yaml::dump($configuration, 99, 2))) { + throw new RuntimeException('Bootstrap site configuration could not be written.'); + } + } + + /** + * Asserts that the given site identifier is available (no existing site folder with the same name). + * + * @param string $siteIdentifier + * @throws RuntimeException + * @return void + */ + private function assertSiteIdentifierAvailable(string $siteIdentifier): void + { + if ($this->siteIdentifierExists($siteIdentifier)) { + throw new RuntimeException('The site identifier "' . $siteIdentifier . '" already exists.'); + } + } + + /** + * Checks whether a site with the given identifier already exists. + * + * @param string $siteIdentifier + * @return bool + */ + private function siteIdentifierExists(string $siteIdentifier): bool + { + foreach ($this->siteConfiguration->getAllExistingSites() as $site) { + if ($site instanceof Site && $site->getIdentifier() === $siteIdentifier) { + return true; + } + } + + return false; + } + + /** + * Checks if any existing site configuration uses the given site base. + * + * @param string $siteBase + * @return bool + */ + private function siteBaseExists(string $siteBase): bool + { + foreach ($this->siteConfiguration->getAllExistingSites() as $site) { + if ($site instanceof Site && (string) $site->getBase() === $siteBase) { + return true; + } + } + + return false; + } + + /** + * Asserts that the given root page title is available (no existing root page with the same title). + * + * @param string $rootPageTitle + * @throws RuntimeException + * @return void + */ + private function assertRootTitleAvailable(string $rootPageTitle): void + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $count = $queryBuilder + ->count('uid') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($rootPageTitle)), + $queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchOne(); + + if ((int) $count > 0) { + throw new RuntimeException('The root page title "' . $rootPageTitle . '" is already in use.'); + } + } + + /** + * Asserts that custom root and viewer slugs are only used together with the root site base "/" to avoid conflicts with existing pages. + * + * @param string $siteBase + * @param string $rootSlug + * @param string $viewerSlug + * @return void + */ + private function assertBaseCompatibleWithSlugs(string $siteBase, string $rootSlug, string $viewerSlug): void + { + if ($siteBase !== '/' && $rootSlug !== '/') { + throw new RuntimeException('Custom root slugs are only supported together with the root site base "/".'); + } + if ($viewerSlug === '/') { + throw new RuntimeException('The viewer slug must not be "/".'); + } + } + + /** + * Normalizes and validates the custom site identifier option. It must only contain lowercase letters, numbers and hyphens. + * + * @param mixed $value + * @return string|null The normalized identifier or null if no valid value is provided. + */ + private function normalizeIdentifierOption(mixed $value): ?string + { + $value = $this->normalizeTextOption($value); + if ($value === null) { + return null; + } + if (preg_match('/^[a-z0-9][a-z0-9-]*$/', $value) !== 1) { + throw new RuntimeException('The --identifier value may only contain lowercase letters, numbers and hyphens.'); + } + return $value; + } + + /** + * Normalizes and validates the custom site base option. It must start with a slash and end with a slash (unless it's just "/"). + * + * @param mixed $value + * @return string|null The normalized base or null if no valid value is provided. + */ + private function normalizeBaseOption(mixed $value): ?string + { + $value = $this->normalizeTextOption($value); + if ($value === null) { + return null; + } + if (!str_starts_with($value, '/')) { + throw new RuntimeException('The --base value must start with "/".'); + } + if (!str_ends_with($value, '/')) { + $value .= '/'; + } + return $value; + } + + /** + * Normalizes and validates the custom slug options. They must start with a slash and must not end with a slash (unless it's just "/"). + * + * @param mixed $value + * @param string $optionName The name of the option for error messages. + * @return string|null The normalized slug or null if no valid value is provided. + */ + private function normalizeSlugOption(mixed $value, string $optionName): ?string + { + $value = $this->normalizeTextOption($value); + if ($value === null) { + return null; + } + if (!str_starts_with($value, '/')) { + throw new RuntimeException('The --' . $optionName . ' value must start with "/".'); + } + return rtrim($value, '/') ?: '/'; + } + + private function normalizeTextOption(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + $value = trim($value); + return $value === '' ? null : $value; + } + + /** + * Creates or updates the root template and ensures the required static TypoScript includes are present. + * + * @param int $rootPageId The UID of the root page. + * @return int The uid of the created or updated template record. + */ + private function ensureTemplate(int $rootPageId): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_template'); + $row = $queryBuilder + ->select('uid', 'include_static_file') + ->from('sys_template') + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($rootPageId, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ->orderBy('sorting') + ->setMaxResults(1) + ->executeQuery() + ->fetchAssociative(); + + if ($row !== false) { + $this->updateRow( + 'sys_template', + (int) $row['uid'], + [ + 'include_static_file' => $this->mergeStaticFiles((string) $row['include_static_file']), + 'title' => self::TEMPLATE_TITLE, + 'root' => 1, + 'clear' => 3, + 'tstamp' => time(), + ] + ); + + return (int) $row['uid']; + } + + return $this->insertRow( + 'sys_template', + [ + 'pid' => $rootPageId, + 'tstamp' => time(), + 'crdate' => time(), + 'deleted' => 0, + 'hidden' => 0, + 'sorting' => 256, + 'title' => self::TEMPLATE_TITLE, + 'root' => 1, + 'clear' => 3, + 'include_static_file' => $this->mergeStaticFiles(''), + 'constants' => '', + 'config' => '', + ] + ); + } + + /** + * Rewrites the bootstrap template constants to the imported page IDs. + * + * @param int $templateId The uid of the template record to update. + * @param int $rootPageId The UID of the root page. + * @param int $viewerPageId The UID of the viewer page. + * @param int $configurationPageId The UID of the configuration page. + * @return void + */ + private function updateTemplate( + int $templateId, + int $rootPageId, + int $viewerPageId, + int $configurationPageId + ): void + { + $constants = [ + 'plugin.tx_dlf.persistence.storagePid = ' . $configurationPageId, + 'plugin.tx_dlf.basicViewer.rootPid = ' . $rootPageId, + 'plugin.tx_dlf.basicViewer.viewerPid = ' . $viewerPageId, + ]; + + $this->updateRow( + 'sys_template', + $templateId, + [ + 'tstamp' => time(), + 'constants' => implode(PHP_EOL, $constants), + ] + ); + } + + /** + * Determines the next sorting value for a new page under the given parent page by finding the current maximum sorting value and adding 256. + * + * @param int $pid + * @return int The next sorting value to use for a new page under the given parent page. + */ + private function nextSortingForPid(int $pid): int + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $sorting = $queryBuilder + ->selectLiteral('MAX(sorting)') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)) + ) + ->executeQuery() + ->fetchOne(); + + return ((int) $sorting) + 256; + } + + /** + * Inserts a new row into the given table with the provided data and returns the uid of the newly created record. + * + * @param string $table The name of the table to insert into. + * @param array $data The data to insert. + * @return int The uid of the newly created record. + */ + private function insertRow(string $table, array $data): int + { + $connection = $this->connectionPool->getConnectionForTable($table); + $connection->insert($table, $data); + return (int) $connection->lastInsertId(); + } + + /** + * Updates a row in the given table with the provided data. + * + * @param string $table The name of the table to update. + * @param int $uid The uid of the row to update. + * @param array $data The data to update. + * @return void + */ + private function updateRow(string $table, int $uid, array $data): void + { + $connection = $this->connectionPool->getConnectionForTable($table); + $connection->update($table, $data, ['uid' => $uid]); + } + + /** + * Merges the required static TypoScript files with any existing includes, ensuring there are no duplicates and that the required files are included. + * + * @param string $includeStaticFile The existing include_static_file value from the template record. + * @return string The merged include_static_file value with required files included. + */ + private function mergeStaticFiles(string $includeStaticFile): string + { + $files = array_filter(array_map('trim', explode(',', $includeStaticFile))); + $files[] = self::BASIC_STATIC_FILE; + $files[] = self::BASIC_VIEWER_STATIC_FILE; + return implode(',', array_values(array_unique($files))); + } +} diff --git a/Configuration/Backend/Modules.php b/Configuration/Backend/Modules.php index 69e626b2ff..e579bafb01 100644 --- a/Configuration/Backend/Modules.php +++ b/Configuration/Backend/Modules.php @@ -26,7 +26,7 @@ 'navigationComponentId' => '@typo3/backend/page-tree/page-tree-element', 'controllerActions' => [ \Kitodo\Dlf\Controller\Backend\NewTenantController::class => [ - 'index','error','addFormat','addMetadata','addSolrCore','addStructure' + 'index','error','createPages','addFormat','addMetadata','addSolrCore','addStructure' ], ], ], diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index 9a54f56f7b..6a46cc7301 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -13,6 +13,12 @@ services: command: 'kitodo:dbdocs' description: 'Generate database rst file.' + Kitodo\Dlf\Command\BootstrapSetupCommand: + tags: + - name: console.command + command: 'kitodo:setup' + description: 'Create a default root page tree and viewer setup.' + Kitodo\Dlf\Command\DeleteCommand: tags: - name: console.command diff --git a/Configuration/TCA/Overrides/sys_template.php b/Configuration/TCA/Overrides/sys_template.php index e15536d96d..15bf8cb29b 100644 --- a/Configuration/TCA/Overrides/sys_template.php +++ b/Configuration/TCA/Overrides/sys_template.php @@ -20,6 +20,12 @@ 'Basic Configuration' ); +\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addStaticFile( + 'dlf', + 'Configuration/TypoScript/BasicViewer/', + 'Install Basic Viewer' +); + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addStaticFile( 'dlf', 'Configuration/TypoScript/Plugins/Feeds/', diff --git a/Documentation/User/BootstrapViewerSetup.rst b/Documentation/User/BootstrapViewerSetup.rst new file mode 100644 index 0000000000..1cb73b9703 --- /dev/null +++ b/Documentation/User/BootstrapViewerSetup.rst @@ -0,0 +1,84 @@ +###################### +Bootstrap Viewer Setup +###################### + +This document describes the manual bootstrap setup for a new Kitodo.Presentation root page tree and its standalone viewer. + +Bootstrap Root Setup +==================== + +The bootstrap root setup is implemented by: + +* ``Classes/Command/BootstrapSetupCommand.php`` +* ``Classes/Service/BootstrapRootSetupService.php`` +* ``Resources/Private/Data/BootstrapSiteConfig.yaml`` + +Run the command with generated defaults: + +:: + + php /var/www/typo3/vendor/bin/typo3 kitodo:setup + +You can also override the generated values: + +:: + + php /var/www/typo3/vendor/bin/typo3 kitodo:setup \ + --identifier=my-instance \ + --base=/my-instance/ \ + --root-title="My Instance" \ + --root-slug=/ \ + --viewer-slug=/viewer + +The command creates: + +* a new root page +* a ``Viewer`` page below the root page +* a ``Kitodo Configuration`` sysfolder below the root page +* a site configuration in ``config/sites//config.yaml`` +* a root ``sys_template`` record with the required static TypoScript includes + +The root page stays the bootstrap landing page. The child ``Viewer`` page is the standalone viewer shell. + +The command also rewrites the template constants so the basic viewer TypoScript receives the generated page IDs: + +* ``plugin.tx_dlf.persistence.storagePid`` +* ``plugin.tx_dlf.basicViewer.rootPid`` +* ``plugin.tx_dlf.basicViewer.viewerPid`` + +Generated Defaults +------------------ + +If no options are provided, the service generates unique values for: + +* the site identifier +* the site base +* the root page title +* the viewer page slug + +For the first bootstrap site, the service prefers ``/`` as the site base if it is still unused. Later setups use ``//``. +The generated site configuration also ships with two enabled languages by default: German on the site base and English below ``/en/``. + +Validation Rules +---------------- + +The bootstrap command validates the custom options as follows: + +* ``--identifier`` may only contain lowercase letters, digits and hyphens. +* ``--base`` must start with ``/`` and is normalized to end with ``/``. +* ``--root-slug`` and ``--viewer-slug`` must start with ``/``. +* ``--viewer-slug`` must not be ``/``. +* Custom root slugs are only supported when the site base is ``/``. +* The site identifier must not exist already. +* The site base must not exist already. +* The root page title must not exist already among root pages. + +Viewer Wiring +============= + +The standalone viewer is configured through: + +* ``Configuration/TypoScript/BasicViewer/setup.typoscript`` +* ``Resources/Private/Templates/Bootstrap/Landing.html`` +* ``Resources/Private/Templates/Bootstrap/Viewer.html`` +* ``Resources/Public/Stylesheets/default-viewer.css`` diff --git a/Documentation/User/Index.rst b/Documentation/User/Index.rst index 71772e5bc5..4d10de624c 100644 --- a/Documentation/User/Index.rst +++ b/Documentation/User/Index.rst @@ -20,6 +20,7 @@ User Manual :hidden: ManualViewerSetup + BootstrapViewerSetup .. _indexing_documents: diff --git a/Resources/Private/Data/BootstrapSiteConfig.yaml b/Resources/Private/Data/BootstrapSiteConfig.yaml new file mode 100644 index 0000000000..8e3b8b6d24 --- /dev/null +++ b/Resources/Private/Data/BootstrapSiteConfig.yaml @@ -0,0 +1,26 @@ +base: '/' +baseVariants: { } +errorHandling: { } +languages: + - title: Deutsch + enabled: true + locale: de_DE.UTF-8 + hreflang: de-DE + base: / + websiteTitle: '' + navigationTitle: German + flag: de + languageId: 0 + - title: English + enabled: true + locale: en_US.UTF-8 + hreflang: en-US + base: /en/ + websiteTitle: '' + navigationTitle: English + fallbackType: strict + fallbacks: '' + flag: us + languageId: 1 +rootPageId: 1 +routes: { } diff --git a/Resources/Private/Language/de.locallang.xlf b/Resources/Private/Language/de.locallang.xlf index d7faae32e4..c38ca9565f 100644 --- a/Resources/Private/Language/de.locallang.xlf +++ b/Resources/Private/Language/de.locallang.xlf @@ -269,6 +269,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf index 8985613325..f59c88d804 100644 --- a/Resources/Private/Language/locallang.xlf +++ b/Resources/Private/Language/locallang.xlf @@ -200,6 +200,21 @@ + + + + + + + + + + + + + + + diff --git a/Resources/Private/Templates/Backend/NewTenant/Root.html b/Resources/Private/Templates/Backend/NewTenant/Root.html new file mode 100644 index 0000000000..7aecdf4bc6 --- /dev/null +++ b/Resources/Private/Templates/Backend/NewTenant/Root.html @@ -0,0 +1,33 @@ + + + + + + + + + + + +
    +

    + +
    + + + + +
    +
    + + diff --git a/Resources/Public/JavaScript/Backend/new-tenant-root.js b/Resources/Public/JavaScript/Backend/new-tenant-root.js new file mode 100644 index 0000000000..6a27b56fe4 --- /dev/null +++ b/Resources/Public/JavaScript/Backend/new-tenant-root.js @@ -0,0 +1,21 @@ +(function () { + function refreshPageTree() { + const moduleBody = document.querySelector('.t3js-module-body[data-refresh-page-tree="1"]'); + if (!moduleBody || !window.top || !window.top.document) { + return; + } + + window.top.document.dispatchEvent(new CustomEvent('typo3:pagetree:refresh')); + + const url = new URL(window.location.href); + url.searchParams.delete('refreshPageTree'); + window.history.replaceState(window.history.state, document.title, url.toString()); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', refreshPageTree, { once: true }); + return; + } + + refreshPageTree(); +})(); diff --git a/Resources/Public/Stylesheets/default-viewer.css b/Resources/Public/Stylesheets/default-viewer.css index 19cd9eef47..c7aff79183 100644 --- a/Resources/Public/Stylesheets/default-viewer.css +++ b/Resources/Public/Stylesheets/default-viewer.css @@ -1,6 +1,7 @@ body.tx-dlf-default-viewer-page { margin: 0; min-height: 100vh; + background: #f5f5f5; } .tx-dlf-default-viewer-shell { @@ -8,68 +9,95 @@ body.tx-dlf-default-viewer-page { --default-link: #1a4d8f; --default-link-hover: #0f539f; --default-accent: #213345; + --default-muted: #708090; --default-surface: rgba(255, 255, 255, 0.94); - --default-border: rgba(24, 33, 44, 0.1); + --default-border: rgba(24, 33, 44, 0.12); --default-shadow: 0 10px 24px rgba(7, 14, 21, 0.14); min-height: 100vh; - font-family: Arial, sans-serif; color: var(--default-ink); - background: whitesmoke; + font-family: "Arial", sans-serif; } -.tx-dlf-default-viewer-viewframe { - display: grid; - grid-template-columns: 360px minmax(0, 1fr); - min-height: 100vh; +.tx-dlf-default-viewer-shell :is(h1, h2, h3, p) { margin-top: 0; } + +.tx-dlf-default-viewer-kicker, +.tx-dlf-default-viewer-panel-kicker { + margin: 0 0 4px; + color: var(--default-muted); + font-size: 11px; + font-weight: bold; + letter-spacing: 0.12em; + text-transform: uppercase; } +.tx-dlf-default-viewer-link, +.tx-dlf-default-viewer-button { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: bold; + text-decoration: none; +} + +.tx-dlf-default-viewer-link { color: var(--default-link); } + +.tx-dlf-default-viewer-link:hover, +.tx-dlf-default-viewer-submenu a:hover, +.tx-dlf-default-viewer-submenu li.tx-dlf-tools-fullscreen a.active { + color: var(--default-link-hover); +} + +.tx-dlf-default-viewer-button { + justify-content: center; + min-height: 38px; + padding: 0 14px; + border: 1px solid var(--default-accent); + border-radius: 999px; + background: var(--default-accent); + color: #fff; + font-size: 13px; +} + +.tx-dlf-default-viewer-button-secondary { + border-color: rgba(33, 51, 69, 0.2); + background: #fff; + color: var(--default-accent); +} .tx-dlf-default-viewer-landing { - min-height: 100vh; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.94)), - linear-gradient(120deg, rgba(26, 77, 143, 0.08), transparent 35%), - whitesmoke; + box-sizing: border-box; + padding: 48px 24px; } .tx-dlf-default-viewer-welcome { - width: min(1080px, calc(100% - 48px)); - margin: 48px auto; + max-width: 1080px; + margin: 0 auto; padding: 40px 44px 56px; border: 1px solid var(--default-border); - border-radius: 20px; + border-radius: 18px; background: var(--default-surface); box-shadow: var(--default-shadow); } .tx-dlf-default-viewer-welcome-hero, -.tx-dlf-default-viewer-welcome-section { - margin-bottom: 40px; - padding: 0; -} +.tx-dlf-default-viewer-welcome-section { margin-bottom: 40px; } -.tx-dlf-default-viewer-welcome-hero h1, -.tx-dlf-default-viewer-welcome-header h2, -.tx-dlf-default-viewer-example-card h3 { - margin: 0; +.tx-dlf-default-viewer-welcome-hero { + padding-bottom: 34px; + border-bottom: 1px solid var(--default-border); } +.tx-dlf-default-viewer-welcome-lead, +.tx-dlf-default-viewer-welcome-section p, +.tx-dlf-default-viewer-format-list { line-height: 1.6; } + .tx-dlf-default-viewer-welcome-lead { max-width: 760px; margin: 18px 0 0; font-size: 18px; - line-height: 1.65; -} - -.tx-dlf-default-viewer-welcome-header { - margin-bottom: 18px; -} - -.tx-dlf-default-viewer-welcome-hero { - padding-bottom: 34px; - border-bottom: 1px solid var(--default-border); } +.tx-dlf-default-viewer-welcome-header { margin-bottom: 18px; } .tx-dlf-default-viewer-example-grid { display: grid; @@ -79,60 +107,35 @@ body.tx-dlf-default-viewer-page { .tx-dlf-default-viewer-example-card { display: grid; - gap: 12px; align-content: start; + gap: 12px; padding: 20px 18px; border: 1px solid var(--default-border); - border-radius: 16px; - background: white; + border-radius: 12px; + background: #fff; } .tx-dlf-default-viewer-example-card p, -.tx-dlf-default-viewer-welcome-section p { - margin: 0; - line-height: 1.6; -} +.tx-dlf-default-viewer-welcome-section p { margin-bottom: 0; } -.tx-dlf-default-viewer-link { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--default-link); - font-weight: 700; - text-decoration: none; -} - -.tx-dlf-default-viewer-link:hover { - color: var(--default-link-hover); -} - -.tx-dlf-default-viewer-link-muted { - color: slategray; -} - -.tx-dlf-default-viewer-link-muted:hover { - color: slategray; -} +.tx-dlf-default-viewer-link-muted, +.tx-dlf-default-viewer-link-muted:hover { color: var(--default-muted); } .tx-dlf-default-viewer-format-list { margin: 0 0 18px; padding-left: 20px; } -.tx-dlf-default-viewer-format-list li { - margin-bottom: 10px; - line-height: 1.6; -} - +.tx-dlf-default-viewer-format-list li { margin-bottom: 10px; } .tx-dlf-default-viewer-input-form { display: grid; gap: 14px; } -.tx-dlf-default-viewer-input-label { - font-weight: 700; -} +.tx-dlf-default-viewer-input-label, +.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages label, +.tx-dlf-metadata-titledata dt { font-weight: bold; } .tx-dlf-default-viewer-input-row { display: grid; @@ -146,8 +149,8 @@ body.tx-dlf-default-viewer-page { min-height: 46px; padding: 0 14px; border: 1px solid var(--default-border); - border-radius: 12px; - background: white; + border-radius: 10px; + background: #fff; color: var(--default-ink); font: inherit; } @@ -164,34 +167,34 @@ body.tx-dlf-default-viewer-page { border-top: 1px solid var(--default-border); } +.tx-dlf-default-viewer-viewframe { + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + min-height: 100vh; +} + .tx-dlf-default-viewer-control-bar { display: grid; - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - gap: 1px; + grid-template-rows: repeat(2, minmax(0, 1fr)); min-height: 100vh; border-right: 1px solid var(--default-border); background: rgba(24, 33, 44, 0.14); } -.tx-dlf-default-viewer-control-panel, -.tx-dlf-default-viewer-card { - border: 1px solid var(--default-border); - background: var(--default-surface); -} - .tx-dlf-default-viewer-control-panel { min-width: 0; padding: 22px 20px; overflow: auto; + border: 1px solid var(--default-border); + background: var(--default-surface); } -.tx-dlf-default-viewer-control-header { - margin-bottom: 16px; -} +.tx-dlf-default-viewer-control-header { margin-bottom: 16px; } -.tx-dlf-default-viewer-main { - min-width: 0; -} +.tx-dlf-default-viewer-control-header h2, +.tx-dlf-default-viewer-provider h1 { margin: 0; } + +.tx-dlf-default-viewer-main { min-width: 0; } .tx-dlf-default-viewer-document-view { position: relative; @@ -199,32 +202,10 @@ body.tx-dlf-default-viewer-page { overflow: hidden; } +.tx-dlf-default-viewer-document-view .tx-dlf-map, .tx-dlf-default-viewer-empty-state { position: absolute; inset: 0; - z-index: 5; - display: grid; - place-items: center; - padding: 24px; -} - -.tx-dlf-default-viewer-empty-notice { - width: min(460px, 100%); - padding: 24px 28px; - border: 1px solid var(--default-border); - border-radius: 18px; - background: var(--default-surface); - box-shadow: var(--default-shadow); - text-align: center; -} - -.tx-dlf-default-viewer-empty-notice h2, -.tx-dlf-default-viewer-empty-notice p { - margin-top: 0; -} - -.tx-dlf-default-viewer-empty-notice p:last-child { - margin-bottom: 0; } .tx-dlf-default-viewer-document-functions { @@ -238,14 +219,19 @@ body.tx-dlf-default-viewer-page { } .tx-dlf-default-viewer-provider, -.tx-dlf-default-viewer-submenu { - pointer-events: auto; +.tx-dlf-default-viewer-submenu, +.tx-dlf-default-viewer-empty-notice, +#tx-dlf-annotationselection, +#tx-dlf-toolbox-fulltext-selection { border: 1px solid var(--default-border); border-radius: 10px; background: var(--default-surface); box-shadow: var(--default-shadow); } +.tx-dlf-default-viewer-provider, +.tx-dlf-default-viewer-submenu { pointer-events: auto; } + .tx-dlf-default-viewer-provider { display: flex; justify-content: space-between; @@ -254,12 +240,6 @@ body.tx-dlf-default-viewer-page { padding: 12px 16px; } -.tx-dlf-default-viewer-provider h1, -.tx-dlf-default-viewer-control-header h2, -.tx-dlf-default-viewer-card h2 { - margin: 0; -} - .tx-dlf-default-viewer-provider h1 { font-size: 26px; line-height: 1; @@ -267,41 +247,9 @@ body.tx-dlf-default-viewer-page { .tx-dlf-default-viewer-provider-actions { display: flex; - align-items: center; gap: 10px; } -.tx-dlf-default-viewer-kicker, -.tx-dlf-default-viewer-panel-kicker { - margin: 0 0 4px; - color: slategray; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.tx-dlf-default-viewer-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 38px; - padding: 0 14px; - border: 1px solid var(--default-accent); - border-radius: 999px; - background: var(--default-accent); - color: white; - font-size: 13px; - font-weight: 700; - text-decoration: none; -} - -.tx-dlf-default-viewer-button-secondary { - background: white; - color: var(--default-accent); - border-color: rgba(33, 51, 69, 0.2); -} - .tx-dlf-default-viewer-submenu { display: flex; flex-wrap: wrap; @@ -311,64 +259,36 @@ body.tx-dlf-default-viewer-page { color: var(--default-accent); } -.tx-dlf-default-viewer-submenu ul, -.tx-dlf-default-viewer-submenu li { +.tx-dlf-default-viewer-submenu :is(ul, li) { margin: 0; padding: 0; list-style: none; } -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-backward, -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-forward, -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages, -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages form { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.tx-dlf-default-viewer-submenu a, -.tx-dlf-default-viewer-submenu span, -.tx-dlf-default-viewer-submenu label, -.tx-dlf-default-viewer-submenu select { +.tx-dlf-default-viewer-submenu :is(a, span, label, select) { color: inherit; font-size: 13px; text-decoration: none; } - -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-backward > div, -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-forward > div, -.tx-dlf-default-viewer-submenu li { +.tx-dlf-default-viewer-submenu :is(.tx-dlf-navigation-backward, .tx-dlf-navigation-forward, +.tx-dlf-navigation-pages, .tx-dlf-navigation-pages form, li, +li[class^="tx-dlf-tools-"] a, li[class^="tx-dlf-tools-"] span) { display: inline-flex; align-items: center; + gap: 8px; +} + +.tx-dlf-default-viewer-submenu :is(li, .tx-dlf-navigation-backward > div, +.tx-dlf-navigation-forward > div, .tx-dlf-navigation-pages) { min-height: 36px; padding: 0 12px; border: 1px solid var(--default-border); border-radius: 7px; background: var(--default-surface); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); -} - -.tx-dlf-default-viewer-submenu a:hover, -.tx-dlf-default-viewer-submenu li.tx-dlf-tools-fullscreen a.active { - color: var(--default-link-hover); -} - -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-backward span, -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-forward span { - opacity: 0.46; -} - -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages { - gap: 10px; - padding: 6px 10px; - background: var(--default-surface); } -.tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages label { - font-weight: 700; -} +.tx-dlf-default-viewer-submenu :is(.tx-dlf-navigation-backward span, .tx-dlf-navigation-forward span) { opacity: 0.46; } .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages select { min-width: 110px; @@ -376,26 +296,20 @@ body.tx-dlf-default-viewer-page { padding: 0 8px; border: 1px solid var(--default-border); border-radius: 6px; - background: white; + background: #fff; } -.tx-dlf-default-viewer-submenu li[class^="tx-dlf-tools-"] span, -.tx-dlf-default-viewer-submenu li[class^="tx-dlf-tools-"] a { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.tx-dlf-default-viewer-document-view .tx-dlf-map { - position: absolute; - inset: 0; +.tx-dlf-default-viewer-empty-state { + z-index: 5; + display: grid; + place-items: center; + padding: 24px; } -#tx-dlf-annotationselection, -#tx-dlf-toolbox-fulltext-selection { - border-radius: 10px; - background: var(--default-surface); - box-shadow: var(--default-shadow); +.tx-dlf-default-viewer-empty-notice { + width: min(460px, 100%); + padding: 24px 28px; + text-align: center; } #tx-dlf-annotationselection { @@ -408,9 +322,7 @@ body.tx-dlf-default-viewer-page { } #tx-dlf-annotationselection:empty, -#tx-dlf-toolbox-fulltext-selection:empty { - display: none; -} +#tx-dlf-toolbox-fulltext-selection:empty { display: none; } .tx-dlf-toolbox-fulltext-container { position: absolute; @@ -431,157 +343,77 @@ body.tx-dlf-default-viewer-page { pointer-events: auto; } -#tx-dlf-toolbox-fulltext-selection .textline, -#tx-dlf-toolbox-fulltext-selection .sp { - display: inline; -} +#tx-dlf-toolbox-fulltext-selection :is(.textline, .sp) { display: inline; } -#tx-dlf-toolbox-fulltext-selection .sp { - white-space: pre; -} - -#tx-dlf-toolbox-fulltext-selection .highlight { - background: khaki; -} +#tx-dlf-toolbox-fulltext-selection .sp { white-space: pre; } -#tx-dlf-toolbox-fulltext-selection .string, -#tx-dlf-toolbox-fulltext-selection .hyp { - color: inherit; -} +#tx-dlf-toolbox-fulltext-selection .highlight { background: #f0e68c; } -.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents, -.tx-dlf-default-viewer-control-panel .tx-dlf-metadata-titledata { - font-size: 14px; -} +.tx-dlf-default-viewer-control-panel :is(.tx-dlf-tableofcontents, .tx-dlf-metadata-titledata) { font-size: 14px; } .tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents ul { margin: 0; padding-left: 18px; } -.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents li { - margin-bottom: 8px; -} +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents li { margin-bottom: 8px; } .tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents a { color: var(--default-link); text-decoration: none; } -.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents a:hover { - text-decoration: underline; -} +.tx-dlf-default-viewer-control-panel .tx-dlf-tableofcontents a:hover { text-decoration: underline; } -.tx-dlf-metadata-titledata { - margin: 0; -} +.tx-dlf-metadata-titledata { margin: 0; } .tx-dlf-metadata-titledata dt { margin: 0 0 4px; color: #425264; - font-weight: 700; -} - -.tx-dlf-metadata-titledata dd { - margin: 0 0 14px; -} - -.tx-dlf-default-viewer-card { - max-width: 760px; - margin: 12vh auto 0; - padding: 28px; - border-radius: 18px; - box-shadow: 0 24px 60px rgba(9, 16, 24, 0.14); -} - -.tx-dlf-default-viewer-actions { - margin: 24px 0 0; } -.tx-dlf-default-viewer-example { - margin-bottom: 0; - word-break: break-all; -} +.tx-dlf-metadata-titledata dd { margin: 0 0 14px; } -body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-viewframe { - grid-template-columns: 1fr; -} +body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-viewframe { grid-template-columns: 1fr; } -body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-control-bar { - display: none; -} +body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-control-bar { display: none; } -body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-document-functions { - padding: 12px; -} - -@media (max-width: 980px) { - .tx-dlf-default-viewer-welcome { - width: min(100% - 32px, 1080px); - margin: 24px auto; - padding: 32px 28px 40px; - } - - .tx-dlf-default-viewer-example-grid { - grid-template-columns: 1fr; - } -} +body.tx-dlf-default-viewer-page.fullscreen .tx-dlf-default-viewer-document-functions { padding: 12px; } @media (max-width: 1180px) { - .tx-dlf-default-viewer-viewframe { - grid-template-columns: 1fr; - } + .tx-dlf-default-viewer-viewframe { grid-template-columns: 1fr; } - .tx-dlf-default-viewer-main { - order: 1; - } + .tx-dlf-default-viewer-main { order: 1; } .tx-dlf-default-viewer-control-bar { order: 2; min-height: auto; grid-template-rows: none; - grid-template-columns: 1fr; border-top: 1px solid var(--default-border); border-right: 0; } } -@media (max-width: 720px) { - .tx-dlf-default-viewer-welcome { - width: min(100% - 24px, 1080px); - margin: 12px auto; - padding: 24px 18px 28px; - } - - .tx-dlf-default-viewer-welcome-lead { - font-size: 16px; - } - - .tx-dlf-default-viewer-input-row { - grid-template-columns: 1fr; - } - - .tx-dlf-default-viewer-input-row .tx-dlf-default-viewer-button { - width: 100%; - } - - .tx-dlf-default-viewer-document-functions { - padding: 12px; - } +@media (max-width: 980px) { + .tx-dlf-default-viewer-landing { padding: 24px 16px; } + .tx-dlf-default-viewer-welcome { padding: 32px 28px 40px; } + .tx-dlf-default-viewer-example-grid { grid-template-columns: 1fr; } +} +@media (max-width: 720px) { + .tx-dlf-default-viewer-landing { padding: 12px; } + .tx-dlf-default-viewer-welcome { padding: 24px 18px 28px; } + .tx-dlf-default-viewer-welcome-lead { font-size: 16px; } + .tx-dlf-default-viewer-input-row { grid-template-columns: 1fr; } + .tx-dlf-default-viewer-input-row .tx-dlf-default-viewer-button { width: 100%; } + .tx-dlf-default-viewer-document-functions { padding: 12px; } .tx-dlf-default-viewer-provider { flex-direction: column; align-items: flex-start; } - .tx-dlf-default-viewer-submenu { - gap: 8px; - } - .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages, - .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages form { - width: 100%; - } + .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages form { width: 100%; } .tx-dlf-default-viewer-submenu .tx-dlf-navigation-pages select { min-width: 0;