diff --git a/.distignore b/.distignore index b6da0486d..f28193b44 100644 --- a/.distignore +++ b/.distignore @@ -4,7 +4,7 @@ # ======================================== # Version control -.git/ +.git .gitignore .gitattributes diff --git a/.github/workflows/ci-browser-security.yml b/.github/workflows/ci-browser-security.yml index 571a22bd8..916fd867c 100644 --- a/.github/workflows/ci-browser-security.yml +++ b/.github/workflows/ci-browser-security.yml @@ -76,7 +76,7 @@ jobs: done < <(find /etc/php -type d -path '*/conf.d' | sort -u) - name: Setup PHP 8.2 tooling - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml, zip, gd, apcu diff --git a/.github/workflows/ci-database-compatibility.yml b/.github/workflows/ci-database-compatibility.yml index f5c7dd091..6f6ca9d07 100644 --- a/.github/workflows/ci-database-compatibility.yml +++ b/.github/workflows/ci-database-compatibility.yml @@ -73,7 +73,7 @@ jobs: persist-credentials: false - name: Setup PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml diff --git a/.github/workflows/ci-deep-regression.yml b/.github/workflows/ci-deep-regression.yml index 4de6f5232..9e02d7164 100644 --- a/.github/workflows/ci-deep-regression.yml +++ b/.github/workflows/ci-deep-regression.yml @@ -98,7 +98,7 @@ jobs: done < <(find /etc/php -type d -path '*/conf.d' | sort -u) - name: Setup PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml, apcu @@ -300,7 +300,7 @@ jobs: [ -z "${php_module}" ] || sudo a2enmod "${php_module}" sudo a2dissite 000-default || true - name: Setup PHP and Node - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, mbstring, curl, zip, gd, intl, xml diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 7fb62bba6..e9c536bca 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -78,7 +78,7 @@ jobs: # ── PHP 8.2 CLI for Composer and tooling ──────────────────────────────── - name: Setup PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml, apcu diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index f528f22d3..ea7fdf83c 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -66,7 +66,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, pdo_mysql @@ -351,6 +351,7 @@ jobs: env: CI_STRICT_TESTS: '1' run: | + php tests/mobile-collections.integration.php php tests/desiderata.integration.php php tests/desiderata-visibility.integration.php php tests/desiderata-extended.integration.php @@ -385,7 +386,7 @@ jobs: with: persist-credentials: false - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml diff --git a/.github/workflows/ci-real-upgrade.yml b/.github/workflows/ci-real-upgrade.yml index d92028728..dea190655 100644 --- a/.github/workflows/ci-real-upgrade.yml +++ b/.github/workflows/ci-real-upgrade.yml @@ -102,7 +102,7 @@ jobs: done < <(find /etc/php -type d -path '*/conf.d' | sort -u) - name: Setup PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml, apcu diff --git a/.github/workflows/ci-security-supply-chain.yml b/.github/workflows/ci-security-supply-chain.yml index bf1e5c89d..14c2689f6 100644 --- a/.github/workflows/ci-security-supply-chain.yml +++ b/.github/workflows/ci-security-supply-chain.yml @@ -119,7 +119,7 @@ jobs: persist-credentials: false - name: Setup PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml, zip @@ -146,6 +146,9 @@ jobs: - name: Verify generated assets match the commit run: git diff --exit-code -- public/assets + - name: Verify packaging from a Git worktree + run: bash tests/release-worktree.test.sh + - name: Build the release twice and require byte-for-byte reproducibility run: | bash bin/build-release.sh --skip-build diff --git a/.github/workflows/ci-upgrade-smoke.yml b/.github/workflows/ci-upgrade-smoke.yml index 1d0427f35..9ddfe81f3 100644 --- a/.github/workflows/ci-upgrade-smoke.yml +++ b/.github/workflows/ci-upgrade-smoke.yml @@ -57,7 +57,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, curl, zip, mbstring diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 299b74364..9a615e0b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: run: bash scripts/ci-verify-release-source.sh - name: Setup PHP 8.2 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, pdo_mysql, mbstring, curl, intl, xml, zip, gd diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 5ea1689c0..02e262490 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' coverage: none @@ -84,7 +84,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli @@ -218,7 +218,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: mysqli, pdo_mysql diff --git a/.rsync-filter b/.rsync-filter index 75fa4e507..e74b6a61d 100644 --- a/.rsync-filter +++ b/.rsync-filter @@ -87,7 +87,7 @@ # ======================================== # Version control -- .git/ +- .git - .gitignore - .gitattributes - .gitmodules diff --git a/CHANGELOG.md b/CHANGELOG.md index b641d087e..c4d2351f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.8.0] + +### Changed +- **A new design for the public site and the account pages (2026).** The catalogue, the book page, the home, the wishlist, loans and reservations, the periodicals, the archive and the events read as one site, built on the Fraunces and Geist typefaces, warm paper and one dark surface. Every colour the site sets as text is computed from the theme so it reads at WCAG AA on every theme. A book without cover art is drawn as a cloth binding with its author, title and publisher; book covers fill the whole book. +- **Two theme options replace the four layout variants.** Under Admin → Themes, *Hero style* is either *Covers* (title on the left and a fan of covers: the latest ones or up to four picked in CMS → Homepage) or *Centred*, and *Card style* is either *Classic* or *Tinted*. The hero photo upload is gone. +- **The catalogue on phones.** Rows line the cover up with the title, the filters fold away, and the list view stays a list after paging. A bar at the bottom, as in the Android app, links Home, Catalogue, Loans, Favourites and the account; it appears at the first scroll and gets out of the way at the footer. +- **The catalogue filters** ([#461](https://github.com/fabiodalez-dev/Pinakes/issues/461)): publishers get the same searchable list as authors, sub-genres can be chosen, and clearing the filters no longer reloads the page. +- **The admin quick search lists books, articles and periodicals as one list** ([#463](https://github.com/fabiodalez-dev/Pinakes/issues/463)): the title that matches what was typed first, then the ones starting with it or containing it, in alphabetical order by the operator's language. +- **The Emeroteca and Book Club addresses follow the site's language** (`/emeroteca`, `/periodicals`, `/zeitschriften`, `/periodiques`, `/tidsskrifter`; `/club-di-lettura`, `/book-club`, `/lesekreis`, `/club-de-lecture`, `/laeseklub`). The old addresses keep working. +- **Interoperability follows the standards more closely.** OAI-PMH: MAG records use the official MAG 2.0.1 namespace, MARC 008 has its 40 positions, MODS names use namePart, languages are ISO 639-2. NCIP 2.02: circulation statuses use the scheme values and due dates fall at the end of the day. SRU: diagnostics in the SRU namespace, CQL NOT and sorting fixed. ResourceSync, BIBFRAME and OpenURL KEV are aligned with their specifications. +- **Archives export MARC 21 by default** (danMARC2 stays available on request), EAD3 places creators in `did/origination`, and a unit can be kept off the site with a *Published* flag honoured by every public page, feed and protocol. + +### Added +- **The VIAF and ISNI identifiers can be entered when an author is created**, not only afterwards. +- **Private book clubs and confirmed invitations.** In a private club, books, discussions, polls and meetings are visible to members only; opening an invitation link shows a confirmation page, so a mail scanner can no longer join anyone. +- **The Android app's push messages are encrypted** (Web Push, RFC 8291) when the device registers its keys. +- **An issue's PDF opens page by page** (HTTP byte ranges) instead of downloading the whole scan first. +- **The admin can take the Emeroteca and the Archive out of the public menu**, and edit the title of the home's events section. + +### Fixed +- **The image uploaded for a CMS page is shown.** Every version up to this one stored it outside the web root while saving a public address, so it answered 404; images left there are moved on the next view. The CMS editor no longer creates an empty duplicate of the About page on an Italian installation seeded with `about-us`. +- **A large PDF or download no longer fails on a low memory limit.** The security-header layer read every response into memory to check whether it was HTML. +- **Plugin review.** Open Library sends the Google Books key in a header, not in the URL; API Book Scraper fails closed on an unreadable key; GoodLib starts with Anna's Archive, Z-Library and its public block off; Discogs, MusicBrainz and Deezer share one throttle per service; Digital Library checks an upload's content before storing it; the mobile API refuses loans, reservations and the wishlist in catalogue-only mode, expires idle tokens and rate-limits messages. +- **Custom CSS, custom scripts and the cookie banner apply on every public page**, account and login pages included. + +Plugin versions: Archives 1.5.2, Book Club 1.4.8, Emeroteca 1.13.2, Mobile API 1.5.1, Desiderata 1.2.0, VIAF Authority 1.1.2, SRU Server 1.4.1, Digital Library 1.4.1, Open Library 1.0.6, API Book Scraper 1.1.3, GoodLib 1.0.2, Discogs 1.1.1. No core migration; the Archives plugin adds its *published* column on its own when it updates. + ## [0.7.94] ### Added diff --git a/README.md b/README.md index 1b774de02..d4c66c68b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,13 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.7.94 — latest +### v0.8.0 — latest + +**A new design.** The public site and the account pages are rebuilt on the 2026 design: one typographic voice, covers that fill the book, a cloth binding for books without artwork, colours that stay readable on every theme, and on phones a bottom bar like the Android app's. Admin → Themes chooses the home hero (a fan of covers, or centred) and the card style. + +**Libraries that exchange data get standard records.** OAI-PMH, NCIP, SRU, ResourceSync, BIBFRAME and OpenURL follow their specifications more closely, MAG uses the official namespace, and archives export MARC 21 and can keep a unit off the site. The quick search lists books and articles as one list with the best match first ([#463](https://github.com/fabiodalez-dev/Pinakes/issues/463)), and the catalogue filters publishers and sub-genres as it does authors ([#461](https://github.com/fabiodalez-dev/Pinakes/issues/461)). No core migration. + +### v0.7.94 **An article has its own page in the admin** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453), [#454](https://github.com/fabiodalez-dev/Pinakes/issues/454)). Like a book, it opens on a page that shows the record (cover, authors, publication, genre, keywords, PDF and exports) with Edit and Delete as buttons, instead of opening straight into its form. The quick search, the Articles list and the author page lead there, and saving the form brings you back to it. diff --git a/app/Controllers/Admin/CmsAdminController.php b/app/Controllers/Admin/CmsAdminController.php index 4c2b9915e..a9ff04f99 100644 --- a/app/Controllers/Admin/CmsAdminController.php +++ b/app/Controllers/Admin/CmsAdminController.php @@ -45,22 +45,16 @@ public function editPage(Request $request, Response $response, array $args): Res $correctSlug = CmsHelper::getRedirectSlug($slug, $currentLocale); if ($correctSlug !== null) { // Redirect 301 to correct localized admin slug + // Keep the query (?saved=1, ?error=…): it carries the save outcome. + $query = $request->getUri()->getQuery(); return $response - ->withHeader('Location', '/admin/cms/' . $correctSlug) + ->withHeader('Location', url('/admin/cms/' . $correctSlug) . ($query !== '' ? '?' . $query : '')) ->withStatus(301); } - // Recupera la pagina dal database (slug + locale) - $stmt = $this->db->prepare(" - SELECT id, slug, locale, title, content, image, meta_description, is_active - FROM cms_pages - WHERE slug = ? AND locale = ? - "); - $stmt->bind_param('ss', $slug, $currentLocale); - $stmt->execute(); - $result = $stmt->get_result(); - $page = $result->fetch_assoc(); - $stmt->close(); + // The page under this slug, or under another slug of the same page + // (an it_IT install seeded with 'about-us'): never a duplicate. + $page = $this->findPageRow($slug, $currentLocale); if (!$page) { // Check if this is a known CMS page that should exist @@ -101,6 +95,9 @@ public function editPage(Request $request, Response $response, array $args): Res } } + // An image uploaded by an older version may sit outside the web root. + \App\Support\CmsImageStorage::ensurePublic($page['image'] ?? null); + // Passa i dati alla view $pageData = $page; $title = sprintf(__('Modifica %s'), $page['title']); @@ -131,26 +128,57 @@ public function updatePage(Request $request, Response $response, array $args): R $metaDescription = $data['meta_description'] ?? ''; $isActive = isset($data['is_active']) ? 1 : 0; - // Aggiorna la pagina (slug + locale) + // The row the editor showed: the same lookup as editPage(), so a page + // stored under another slug of the same page is the one updated. + $page = $this->findPageRow($slug, $currentLocale); + if ($page === null) { + return $response + ->withHeader('Location', url('/admin/cms/' . $slug . '?error=db')) + ->withStatus(302); + } + $pageRowId = (int) $page['id']; + $stmt = $this->db->prepare(" UPDATE cms_pages SET title = ?, content = ?, image = ?, meta_description = ?, is_active = ?, updated_at = NOW() - WHERE slug = ? AND locale = ? + WHERE id = ? "); - $stmt->bind_param('ssssiss', $title, $content, $image, $metaDescription, $isActive, $slug, $currentLocale); + $stmt->bind_param('ssssii', $title, $content, $image, $metaDescription, $isActive, $pageRowId); - if ($stmt->execute()) { - $stmt->close(); - return $response - ->withHeader('Location', '/admin/cms/' . $slug . '?saved=1') - ->withStatus(302); - } else { - $error = $this->db->error; - $stmt->close(); - return $response - ->withHeader('Location', '/admin/cms/' . $slug . '?error=db') - ->withStatus(302); - } + $ok = $stmt->execute(); + $stmt->close(); + // Back to the editor under the locale's own slug (no extra 301 hop). + return $response + ->withHeader('Location', url('/admin/cms/' . CmsHelper::getLocalizedSlug($slug, $currentLocale) . ($ok ? '?saved=1' : '?error=db'))) + ->withStatus(302); + } + + /** + * The cms_pages row for a slug in a locale: the exact slug first, then any + * other slug of the same page (CmsHelper's map), as the public page does. + * + * @return array|null + */ + private function findPageRow(string $slug, string $locale): ?array + { + $pageId = CmsHelper::getPageIdFromSlug($slug); + $variants = $pageId !== null ? CmsHelper::getSlugsForPage($pageId) : []; + $slugs = array_values(array_unique(array_merge([$slug], $variants))); + $placeholders = implode(',', array_fill(0, count($slugs), '?')); + $stmt = $this->db->prepare(" + SELECT id, slug, locale, title, content, image, meta_description, is_active + FROM cms_pages + WHERE slug IN ($placeholders) AND locale = ? + ORDER BY slug = ? DESC, id ASC + LIMIT 1 + "); + $params = array_merge($slugs, [$locale, $slug]); + $stmt->bind_param(str_repeat('s', count($params)), ...$params); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result instanceof \mysqli_result ? $result->fetch_assoc() : null; + $stmt->close(); + return is_array($row) ? $row : null; } public function uploadImage(Request $request, Response $response): Response @@ -201,20 +229,22 @@ public function uploadImage(Request $request, Response $response): Response return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } - // SECURITY: Store uploads outside web directory to prevent direct access - $baseDir = realpath(__DIR__ . '/../../../storage/uploads'); - if ($baseDir === false) { - error_log("Upload base directory not found"); + // The image is shown on a public page, so it is stored where its URL + // (/uploads/cms/) is served from: public/uploads/cms. It used to + // be written to storage/uploads/cms, outside the web root, while the + // same /uploads/cms URL was saved: every CMS image answered 404. + $uploadPath = \App\Support\CmsImageStorage::publicDir(); + if (!is_dir($uploadPath) && !@mkdir($uploadPath, 0755, true) && !is_dir($uploadPath)) { + \App\Support\SecureLogger::error('[CMS] upload directory cannot be created: ' . $uploadPath); $payload = json_encode(['error' => __('Errore di configurazione del server.')] ); $response->getBody()->write($payload); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } - - $uploadPath = $baseDir . '/cms'; - - // Crea directory se non esiste - if (!is_dir($uploadPath)) { - mkdir($uploadPath, 0755, true); + $baseDir = realpath($uploadPath); + if ($baseDir === false) { + $payload = json_encode(['error' => __('Errore di configurazione del server.')] ); + $response->getBody()->write($payload); + return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } // SECURITY: Generate cryptographically secure random filename diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 04d7a4940..bc6fb18b4 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -200,7 +200,10 @@ public function login(Request $request, Response $response, mysqli $db): Respons } elseif (in_array($row['tipo_utente'], ['admin', 'staff'], true)) { $redirectUrl = '/admin/dashboard'; } else { - $redirectUrl = '/user/dashboard'; + // The dashboard in the reader's language, set just above from their + // profile (/utente/bacheca for an Italian reader), not the English + // path, which also answers but is not canonical there. + $redirectUrl = RouteTranslator::route('user_dashboard'); } return $response->withHeader('Location', $redirectUrl)->withStatus(302); diff --git a/app/Controllers/AutoriController.php b/app/Controllers/AutoriController.php index 8b3a244a4..fa378bf65 100644 --- a/app/Controllers/AutoriController.php +++ b/app/Controllers/AutoriController.php @@ -129,7 +129,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $collegamenti = $this->buildCollegamentiJson($data); try { - $repo->create([ + $newAuthorId = $repo->create([ 'nome' => trim($data['nome'] ?? ''), 'pseudonimo' => trim($data['pseudonimo'] ?? ''), 'data_nascita' => $data['data_nascita'] ?? null, @@ -154,6 +154,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons if ($photo['deleteOnSuccess'] !== null) { $this->deleteLocalPhoto($photo['deleteOnSuccess']); } + // Plugins that add fields to the create form (author.create.fields) + // store them now that the row exists. A plugin failure never undoes + // the author the librarian just created. + try { + \App\Support\Hooks::do('author.created', [$newAuthorId, $data]); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('author.created listener failed: ' . $e->getMessage()); + } return $response->withHeader('Location', url('/admin/authors'))->withStatus(302); } diff --git a/app/Controllers/CmsController.php b/app/Controllers/CmsController.php index 2a29b1b7c..ebf80c66b 100644 --- a/app/Controllers/CmsController.php +++ b/app/Controllers/CmsController.php @@ -77,6 +77,8 @@ public function showPage(Request $request, Response $response, \mysqli $db, arra $title = $page['title']; $content = ContentSanitizer::normalizeExternalAssets($page['content'] ?? ''); $image = $page['image']; + // An image uploaded by an older version may sit outside the web root. + \App\Support\CmsImageStorage::ensurePublic(is_string($image) ? $image : null); $seoDescription = $page['meta_description'] ?? ''; ob_start(); @@ -138,7 +140,7 @@ public function editHome(Request $request, Response $response, \mysqli $db, arra // Carica tutti i contenuti della home (inclusi campi SEO completi) $stmt = $db->prepare(" - SELECT id, section_key, title, subtitle, content, button_text, button_link, background_image, + SELECT id, section_key, title, subtitle, content, button_text, button_link, seo_title, seo_description, seo_keywords, og_image, og_title, og_description, og_type, og_url, twitter_card, twitter_title, twitter_description, twitter_image, @@ -180,6 +182,27 @@ public function editHome(Request $request, Response $response, \mysqli $db, arra // Include the specific view first ob_start(); + // The hero's picked cover books, with their titles for the picker. + $heroCoverConfig = \App\Controllers\FrontendController::heroCoverConfig($sections['hero']['content'] ?? null); + $heroCoverBooks = []; + if ($heroCoverConfig['books'] !== []) { + $marks = implode(',', array_fill(0, count($heroCoverConfig['books']), '?')); + $coverStmt = $db->prepare("SELECT id, titolo, copertina_url FROM libri WHERE deleted_at IS NULL AND id IN ($marks)"); + if ($coverStmt !== false) { + $coverStmt->bind_param(str_repeat('i', count($heroCoverConfig['books'])), ...$heroCoverConfig['books']); + $coverStmt->execute(); + $coverRows = []; + foreach ($coverStmt->get_result()->fetch_all(MYSQLI_ASSOC) as $coverRow) { + $coverRows[(int) $coverRow['id']] = $coverRow; + } + $coverStmt->close(); + foreach ($heroCoverConfig['books'] as $coverId) { + if (isset($coverRows[$coverId])) { + $heroCoverBooks[] = $coverRows[$coverId]; + } + } + } + } include __DIR__ . '/../Views/cms/edit-home.php'; $content = ob_get_clean(); @@ -192,34 +215,9 @@ public function editHome(Request $request, Response $response, \mysqli $db, arra return $response; } - /** - * Map a PHP file-upload error code to a user-facing message, or null when - * there is nothing to report (UPLOAD_ERR_OK / UPLOAD_ERR_NO_FILE). - * - * #292: a hero photo bigger than upload_max_filesize arrives with a non-OK - * error code BEFORE the app can validate it. The upload block only ran on - * UPLOAD_ERR_OK, so the failure fell through silently and the page reported - * success with no image. Extracted so the mapping is unit-testable without a - * specific php.ini (the E2E INI_SIZE case needs upload_max < post_max, which - * not every environment has). - */ - public static function heroUploadErrorMessage(int $err): ?string - { - return match ($err) { - UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE => null, - UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => - "L'immagine supera il limite di upload del server. Riduci la dimensione dell'immagine, oppure aumenta upload_max_filesize e post_max_size nella configurazione PHP.", - UPLOAD_ERR_PARTIAL => "L'upload dell'immagine è stato interrotto. Riprova.", - UPLOAD_ERR_NO_TMP_DIR => "Cartella temporanea mancante sul server. Contatta l'amministratore.", - UPLOAD_ERR_CANT_WRITE => "Impossibile scrivere il file sul server. Controlla i permessi.", - default => "Errore durante l'upload dell'immagine (codice {$err}).", - }; - } - public function updateHome(Request $request, Response $response, \mysqli $db, array $args): Response { $data = $request->getParsedBody(); - $files = $request->getUploadedFiles(); // CRITICAL: Set UTF-8 charset to prevent corruption of Greek/Unicode characters $db->set_charset('utf8mb4'); @@ -265,113 +263,12 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar } $heroData['button_link'] = $buttonLink; - $bgImagePath = null; - - // #292: the browser sent a hero image but PHP rejected it BEFORE the - // app could validate/save it — almost always because the file exceeds - // upload_max_filesize / post_max_size on a self-hosted install (a - // phone photo easily beats the 2M PHP default). This used to fall - // through silently: the block below (getError() === OK) was skipped, - // $errors stayed empty, the UPSERT ran WITHOUT a background, and the - // page reported "saved successfully". Surface a clear error instead. - $heroUpload = $files['hero_background'] ?? null; - $heroUploadErr = $heroUpload !== null ? $heroUpload->getError() : UPLOAD_ERR_NO_FILE; - $heroUploadError = self::heroUploadErrorMessage($heroUploadErr); - if ($heroUploadError !== null) { - $errors[] = $heroUploadError; - } - - // SECURITY: Enhanced file upload validation - if (isset($files['hero_background']) && $files['hero_background']->getError() === UPLOAD_ERR_OK) { - $uploadedFile = $files['hero_background']; - $filename = $uploadedFile->getClientFilename(); - $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - - // SECURITY: Validate file extension - $allowedExtensions = ['jpg', 'jpeg', 'png', 'webp']; - if (!in_array($extension, $allowedExtensions)) { - $errors[] = 'Formato immagine non supportato. Usa JPG, PNG o WebP.'; - } else { - // SECURITY: Validate file size (max 5MB) - if ($uploadedFile->getSize() > 5 * 1024 * 1024) { - $errors[] = 'L\'immagine è troppo grande. Max 5MB.'; - } else { - // SECURITY: Validate MIME type with magic number check - $tmpPath = $uploadedFile->getStream()->getMetadata('uri'); - $finfo = new \finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($tmpPath); - - $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; - if (!in_array($mimeType, $allowedMimes)) { - $errors[] = 'Tipo di file non valido. Il file deve essere un\'immagine reale.'; - } else { - // SECURITY: Secure path handling to prevent directory traversal - $baseDir = realpath(__DIR__ . '/../../public/uploads'); - if ($baseDir === false) { - \App\Support\SecureLogger::error('CmsController: Upload base directory not found'); - $errors[] = 'Errore di configurazione directory upload.'; - } else { - $targetDir = $baseDir . '/assets'; - - // Create directory if it doesn't exist - if (!is_dir($targetDir)) { - mkdir($targetDir, 0755, true); - } - - // SECURITY: Generate cryptographically secure random filename - $randomSuffix = ''; - try { - $randomSuffix = bin2hex(random_bytes(8)); - } catch (\Throwable $e) { - \App\Support\SecureLogger::error('CmsController: random_bytes() failed: ' . $e->getMessage()); - $errors[] = 'Errore di sistema. Riprova più tardi.'; - } - - if (empty($errors)) { - $newFilename = 'hero_bg_' . $randomSuffix . '.' . $extension; - // Sanitize filename to prevent null byte injection - $newFilename = str_replace("\\0", '', $newFilename); - $uploadPath = $targetDir . '/' . basename($newFilename); - - // SECURITY: Verify final path is within allowed directory - $realUploadPath = realpath(dirname($uploadPath)); - if ($realUploadPath === false || strpos($realUploadPath, $baseDir) !== 0) { - \App\Support\SecureLogger::error('CmsController: Path traversal attempt detected'); - $errors[] = 'Percorso file non valido.'; - } else { - try { - $uploadedFile->moveTo($uploadPath); - // SECURITY: Set secure file permissions - @chmod($uploadPath, 0644); - // #292: the file is written under - // public/uploads/assets, so the stored - // URL must be /uploads/assets/… — - // /assets/ resolves to public/assets - // (a different dir) and 404s, so the - // hero image never rendered even when - // the upload succeeded. - $bgImagePath = '/uploads/assets/' . $newFilename; - } catch (\Throwable $e) { - \App\Support\SecureLogger::error('CmsController: Image upload error: ' . $e->getMessage()); - $errors[] = 'Errore durante l\'upload dell\'immagine. Riprova.'; - } - } - } - } - } - } - } - } + // The hero shows a fan of covers (2026 design): there is no + // background photo setting any more. The legacy `background_image` + // column is neither read nor written. if (empty($errors)) { // UPSERT: Insert if not exists, update if exists - $backgroundImage = null; - if (isset($heroData['remove_background']) && $heroData['remove_background'] == '1') { - $backgroundImage = null; - } elseif ($bgImagePath) { - $backgroundImage = $bgImagePath; - } - // SEO fields for hero (base) $seoTitle = $sanitizeText($heroData['seo_title'] ?? ''); $seoDescription = $sanitizeText($heroData['seo_description'] ?? ''); @@ -392,19 +289,18 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar $stmt = $db->prepare(" INSERT INTO home_content ( - section_key, title, subtitle, button_text, button_link, background_image, + section_key, title, subtitle, button_text, button_link, seo_title, seo_description, seo_keywords, og_image, og_title, og_description, og_type, og_url, twitter_card, twitter_title, twitter_description, twitter_image, is_active, display_order ) - VALUES ('hero', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, -1) + VALUES ('hero', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, -1) ON DUPLICATE KEY UPDATE title = VALUES(title), subtitle = VALUES(subtitle), button_text = VALUES(button_text), button_link = VALUES(button_link), - background_image = IF(VALUES(background_image) IS NOT NULL OR ? = 1, VALUES(background_image), background_image), seo_title = VALUES(seo_title), seo_description = VALUES(seo_description), seo_keywords = VALUES(seo_keywords), @@ -418,14 +314,12 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar twitter_description = VALUES(twitter_description), twitter_image = VALUES(twitter_image) "); - $removeBackground = isset($heroData['remove_background']) && $heroData['remove_background'] == '1' ? 1 : 0; $stmt->bind_param( - 'sssssssssssssssssi', + 'ssssssssssssssss', $heroData['title'], $heroData['subtitle'], $heroData['button_text'], $heroData['button_link'], - $backgroundImage, $seoTitle, $seoDescription, $seoKeywords, @@ -437,11 +331,36 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar $twitterCard, $twitterTitle, $twitterDescription, - $twitterImage, - $removeBackground + $twitterImage ); $stmt->execute(); $stmt->close(); + + // Hero covers (2026 design): the latest catalogued covers, or up + // to four books picked here, in the order picked. Stored as JSON + // in the hero row's `content`, read by FrontendController::heroCovers(). + if (isset($heroData['cover_mode'])) { + $coverIds = []; + foreach ((array) ($heroData['cover_books'] ?? []) as $coverId) { + if (!is_int($coverId) && !is_string($coverId)) { + continue; + } + $coverId = filter_var($coverId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 2147483647]]); + if ($coverId !== false && !in_array($coverId, $coverIds, true) && count($coverIds) < 4) { + $coverIds[] = $coverId; + } + } + $coverConfig = json_encode([ + 'cover_mode' => $heroData['cover_mode'] === 'selected' ? 'selected' : 'latest', + 'cover_books' => $coverIds, + ]); + $coverStmt = $db->prepare("UPDATE home_content SET content = ? WHERE section_key = 'hero'"); + if ($coverStmt !== false && $coverConfig !== false) { + $coverStmt->bind_param('s', $coverConfig); + $coverStmt->execute(); + $coverStmt->close(); + } + } } } @@ -544,6 +463,26 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar $stmt->close(); } + // Events section (the home's list of upcoming events) + if (isset($data['events']) && empty($errors)) { + $events = $data['events']; + $title = $sanitizeText($events['title'] ?? ''); + $subtitle = $sanitizeText($events['subtitle'] ?? ''); + $isActive = isset($events['is_active']) ? 1 : 0; + + $stmt = $db->prepare(" + INSERT INTO home_content (section_key, title, subtitle, is_active, display_order) + VALUES ('events', ?, ?, ?, 9) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + subtitle = VALUES(subtitle), + is_active = VALUES(is_active) + "); + $stmt->bind_param('ssi', $title, $subtitle, $isActive); + $stmt->execute(); + $stmt->close(); + } + // Text content section if (isset($data['text_content']) && empty($errors)) { $textContent = $data['text_content']; diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 88f057070..a62f45fdf 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -2352,9 +2352,10 @@ private function enrichBookWithScrapedData(\mysqli $db, int $bookId, array $csvD // Classificazione Dewey if ($allow('dewey') && empty($csvData['classificazione_dewey'] ?? null) && !empty($scrapedData['classificazione_dewey'] ?? null)) { - // Validate Dewey format: 3 digits optionally followed by decimal point and 1-4 digits + // Validate Dewey format: 3 digits optionally followed by a decimal + // point and up to 12 digits (DDC 23 numbers such as 973.0496073). $deweyCode = trim((string) $scrapedData['classificazione_dewey']); - if (preg_match('/^[0-9]{3}(\.[0-9]{1,4})?$/', $deweyCode)) { + if (preg_match('/^[0-9]{3}(\.[0-9]{1,12})?$/', $deweyCode)) { $updates[] = 'classificazione_dewey = ?'; $params[] = $deweyCode; $types .= 's'; diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index a772c1076..2540c9601 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -34,7 +34,7 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe // events) clear the 'home_' prefix via ContentCache — which also // covers the home_api_count_* keys below — while the TTL covers // loan-driven availability drift. - $homeData = \App\Support\QueryCache::remember('home_page_data_v1', function () use ($db) { + $homeData = \App\Support\QueryCache::remember('home_page_data_v2', function () use ($db) { return $this->buildHomePageData($db); }, 300); @@ -57,7 +57,21 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe } $homeEvents = $homeData['homeEvents']; $heroTotalBooks = $homeData['totalBooks']; + $heroCovers = $homeData['heroCovers'] ?? []; + // The centred hero style shows no cover fan: drop the covers so the + // page neither downloads them nor preloads the first one. Read per + // request: the style belongs to the active theme, not to the shared + // home cache. + try { + $heroStyle = (new \App\Support\ThemeManager($db))->getPublicStyle()['hero_style']; + if ($heroStyle === 'centered') { + $heroCovers = []; + } + } catch (\Throwable $e) { + // Keep the covers: the fan is hidden by CSS anyway. + } $heroAvailableBooks = $homeData['availableBooks']; + $heroTotalGenres = $homeData['totalGenres'] ?? null; $homeEventsEnabled = $homeData['eventsFeatureEnabled'] && !empty($homeEvents); @@ -111,12 +125,12 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe // OG URL (priority: custom og_url > canonical URL) $ogUrl = !empty($hero['og_url']) ? $hero['og_url'] : $seoCanonical; - // OG Image (priority: custom og_image > hero background > app logo > default cover) + // OG Image (priority: custom og_image > app logo > default cover). + // The retired hero background photo is no longer a fallback: it is + // not shown on the page and the admin can no longer change it. $ogImage = $defaultSocialImage; if (!empty($hero['og_image'])) { $ogImage = HtmlHelper::absoluteUrl($hero['og_image']); - } elseif (!empty($hero['background_image'])) { - $ogImage = HtmlHelper::absoluteUrl($hero['background_image']); } elseif ($brandLogoUrl !== '') { $ogImage = $brandLogoUrl; } @@ -142,14 +156,12 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe (!empty($hero['subtitle']) ? $hero['subtitle'] : ($footerDescription ?: __('Esplora il nostro vasto catalogo di libri, prenota i tuoi titoli preferiti e scopri nuove letture'))))); - // Twitter Image (priority: custom twitter_image > og_image > hero background > app logo > default cover) + // Twitter Image (priority: custom twitter_image > og_image > app logo > default cover) $twitterImage = $defaultSocialImage; if (!empty($hero['twitter_image'])) { $twitterImage = HtmlHelper::absoluteUrl($hero['twitter_image']); } elseif (!empty($hero['og_image'])) { $twitterImage = HtmlHelper::absoluteUrl($hero['og_image']); - } elseif (!empty($hero['background_image'])) { - $twitterImage = HtmlHelper::absoluteUrl($hero['background_image']); } elseif ($brandLogoUrl !== '') { $twitterImage = $brandLogoUrl; } @@ -251,8 +263,10 @@ public function catalog(Request $request, Response $response, mysqli $db): Respo // Extra results from plugins (e.g. archive units) when a search is active. $searchTerm = trim((string) ($filters['search'] ?? '')); /** @var array> $archiveResults */ - $archiveResults = $searchTerm !== '' - ? \App\Support\Hooks::apply('frontend.catalog.archive_results', [], [$searchTerm]) + // The archive lookup is four leading-wildcard LIKEs over every unit: + // only from the third character, when the term can actually select. + $archiveResults = mb_strlen($searchTerm) >= 3 + ? $this->collectArchiveResults($searchTerm) : []; // Federated-search hint: the catalogue search only reads @@ -344,7 +358,7 @@ public function catalog(Request $request, Response $response, mysqli $db): Respo $total_pages = ceil($total_books / $limit); if ($mixed !== null) { - $articlePath = url('/emeroteca/articoli'); + $articlePath = url(\App\Support\RouteTranslator::route('periodicals') . '/articoli'); $externalSearchSuggestions = array_values(array_filter($externalSearchSuggestions, static fn(array $suggestion): bool => !str_starts_with($suggestion['url'], $articlePath . '?'))); } @@ -398,6 +412,16 @@ public function catalog(Request $request, Response $response, mysqli $db): Respo ->withHeader(\App\Support\LiteSpeedCache::MARKER_HEADER, 'catalog'); } + /** Public archive snippets, bounded and stripped of every non-display field. */ + private function collectArchiveResults(string $query): array + { + try { $results = \App\Support\Hooks::apply('frontend.catalog.archive_results', [], [mb_substr($query, 0, 200)]); } + catch (\Throwable $e) { \App\Support\SecureLogger::error('Archive catalogue search failed: ' . $e->getMessage()); return []; } + if (!is_array($results)) { return []; } + return array_map(static fn(array $row): array => array_intersect_key($row, array_flip(['id', 'label', 'reference_code', 'url'])), + array_slice(array_values(array_filter($results, 'is_array')), 0, 6)); + } + public function catalogAPI(Request $request, Response $response, mysqli $db): Response { $params = $request->getQueryParams(); @@ -413,16 +437,12 @@ public function catalogAPI(Request $request, Response $response, mysqli $db): Re $query_params = $where_conditions['params']; $param_types = $where_conditions['types']; - // FIX F001: removed archive results hook from catalogAPI() to avoid - // returning archive matches in the search-as-you-type JSON payload. - // catalog() still renders archives in its empty-state block. - - // Same search/browse split as catalog(): this endpoint feeds the - // search-as-you-type grid, so a term present here is the visitor - // asking for a title by name. $searchTerm is derived the same way - // catalog() derives it — catalogAPI() has no archive hook to have - // computed it earlier. $searchTerm = trim((string) ($filters['search'] ?? '')); + // Public projection only: the same snippets as the server-rendered catalogue. + $archiveResults = mb_strlen($searchTerm) >= 3 ? $this->collectArchiveResults($searchTerm) : []; + ob_start(); + include __DIR__ . '/../Views/frontend/partials/catalog-archive-results.php'; + $archiveHtml = (string) ob_get_clean(); $visibility = $searchTerm !== '' ? \App\Support\BookVisibility::discoverable($db, 'l') : \App\Support\BookVisibility::catalogue($db, 'l'); @@ -567,6 +587,7 @@ public function catalogAPI(Request $request, Response $response, mysqli $db): Re $data = [ 'html' => $html, + 'archive_html' => $archiveHtml, 'pagination' => [ 'current_page' => $page, 'total_pages' => $total_pages, @@ -2656,6 +2677,82 @@ private function collectGenreTreeIds(array $childrenByParent, int $rootId): arra return $ids; } + /** + * The hero's cover settings, stored as JSON in home_content.content of the + * 'hero' row: {"cover_mode": "latest"|"selected", "cover_books": [ids]}. + * Anything else reads as the default, the latest covers. + * + * @return array{mode: string, books: list} + */ + public static function heroCoverConfig(?string $raw): array + { + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; + $mode = is_array($data) && ($data['cover_mode'] ?? '') === 'selected' ? 'selected' : 'latest'; + $books = []; + foreach ((array) (is_array($data) ? ($data['cover_books'] ?? []) : []) as $id) { + $id = (int) $id; + if ($id > 0 && !in_array($id, $books, true)) { + $books[] = $id; + } + } + return ['mode' => $mode, 'books' => array_slice($books, 0, 4)]; + } + + /** + * Up to four books with a cover for the home hero: the ones the CMS picked, + * in the order it picked them, or the latest catalogued covers. A picked + * book that lost its cover, was deleted or left the catalogue is skipped; + * when none is left the hero falls back to the latest covers. + * + * @return list> + */ + private function heroCovers(mysqli $db, ?string $raw, string $latestSort = 'created_at'): array + { + // "Latest" follows the same order as the home's latest-arrivals section. + $latestSort = in_array($latestSort, ['created_at', 'updated_at'], true) ? $latestSort : 'created_at'; + $config = self::heroCoverConfig($raw); + $select = "SELECT l.id, l.titolo, l.copertina_url, + (SELECT " . \App\Support\AuthorName::displaySql('a') . " FROM libri_autori la JOIN autori a ON la.autore_id = a.id + WHERE la.libro_id = l.id AND la.ruolo IN ('principale','co-autore') ORDER BY la.ruolo = 'principale' DESC LIMIT 1) AS autore, + (SELECT a.nome FROM libri_autori la JOIN autori a ON la.autore_id = a.id + WHERE la.libro_id = l.id AND la.ruolo IN ('principale','co-autore') ORDER BY la.ruolo = 'principale' DESC LIMIT 1) AS autore_principale_nome + FROM libri l + WHERE l.deleted_at IS NULL AND " . \App\Support\BookVisibility::catalogue($db, 'l') . " + AND l.copertina_url IS NOT NULL AND l.copertina_url <> '' AND l.copertina_url NOT LIKE '%placeholder%'"; + $rows = []; + try { + if ($config['mode'] === 'selected' && $config['books'] !== []) { + $marks = implode(',', array_fill(0, count($config['books']), '?')); + $stmt = $db->prepare($select . " AND l.id IN ($marks)"); + if ($stmt !== false) { + $stmt->bind_param(str_repeat('i', count($config['books'])), ...$config['books']); + $stmt->execute(); + $byId = []; + foreach ($stmt->get_result()->fetch_all(MYSQLI_ASSOC) as $row) { + $byId[(int) $row['id']] = $row; + } + $stmt->close(); + foreach ($config['books'] as $id) { + if (isset($byId[$id])) { + $rows[] = $byId[$id]; + } + } + } + } + if ($rows === []) { + $result = $db->query($select . " ORDER BY l.{$latestSort} DESC, l.id DESC LIMIT 4"); + if ($result instanceof \mysqli_result) { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->free(); + } + } + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[Home] hero covers: ' . $e->getMessage()); + return []; + } + return $rows; + } + /** * Build the cacheable, visitor-independent home page dataset. * @@ -2665,14 +2762,15 @@ private function collectGenreTreeIds(array $childrenByParent, int $rootId): arra * * @return array{homeContent: array, sectionsOrdered: array, latest_books: array, * latestBooksTotal: int, genres_with_books: array, genreCarouselEnabled: bool, - * eventsFeatureEnabled: bool, homeEvents: array, totalBooks: int, availableBooks: int} + * eventsFeatureEnabled: bool, homeEvents: array, totalBooks: int, availableBooks: int, + * totalGenres: int, heroCovers: list>} */ private function buildHomePageData(mysqli $db): array { // Carica i contenuti CMS della home (inclusi campi SEO completi) $homeContent = []; $sectionsOrdered = []; - $query_home = "SELECT section_key, title, subtitle, content, button_text, button_link, background_image, + $query_home = "SELECT section_key, title, subtitle, content, button_text, button_link, seo_title, seo_description, seo_keywords, og_image, og_title, og_description, og_type, og_url, twitter_card, twitter_title, twitter_description, twitter_image, @@ -2849,7 +2947,7 @@ private function buildHomePageData(mysqli $db): array } } - // This payload is stored in the SHARED home cache (home_page_data_v1). + // This payload is stored in the SHARED home cache (home_page_data_v2). // Strip live availability (copie_*/stato — a stale count is a // double-loan risk) AND the private/non-shareable columns (l.* pulled // private_comment, lending_patron, search_index, …). Availability is @@ -2862,6 +2960,7 @@ private function buildHomePageData(mysqli $db): array return [ 'homeContent' => $homeContent, 'sectionsOrdered' => $sectionsOrdered, + 'heroCovers' => $this->heroCovers($db, $homeContent['hero']['content'] ?? null, $latestBooksSort), 'latest_books' => $latest_books, 'latestBooksTotal' => $totalBooks, 'genres_with_books' => $genres_with_books, @@ -2870,6 +2969,7 @@ private function buildHomePageData(mysqli $db): array 'homeEvents' => $homeEvents, 'totalBooks' => $totalBooks, 'availableBooks' => $availableBooks, + 'totalGenres' => count(array_filter($allGenres, static fn (array $g): bool => $g['parent_id'] === null)), ]; } diff --git a/app/Controllers/PluginController.php b/app/Controllers/PluginController.php index 5e61f4fa0..73a05b474 100644 --- a/app/Controllers/PluginController.php +++ b/app/Controllers/PluginController.php @@ -297,8 +297,6 @@ public function settingsPage(Request $request, Response $response, array $args): */ public function updateSettings(Request $request, Response $response, array $args): Response { - error_log('[PluginController] updateSettings called'); - if (!isset($_SESSION['user']) || $_SESSION['user']['tipo_utente'] !== 'admin') { error_log('[PluginController] Unauthorized access attempt'); $response->getBody()->write(json_encode([ @@ -310,11 +308,8 @@ public function updateSettings(Request $request, Response $response, array $args // CSRF validated by CsrfMiddleware $body = $request->getParsedBody(); - // Log only plugin ID, not full body (may contain API keys) - error_log('[PluginController] Request received for plugin settings update'); $pluginId = (int) $args['id']; - error_log('[PluginController] Plugin ID: ' . $pluginId); $plugin = $this->pluginManager->getPlugin($pluginId); @@ -337,8 +332,6 @@ public function updateSettings(Request $request, Response $response, array $args return $this->settingsPage($request, $response, $args); } - error_log('[PluginController] Plugin name: ' . $plugin['name']); - $settings = $body['settings'] ?? []; if (!is_array($settings)) { error_log('[PluginController] Invalid settings format'); @@ -354,10 +347,15 @@ public function updateSettings(Request $request, Response $response, array $args // Open Library: Google Books API key $apiKey = trim((string) ($settings['google_books_api_key'] ?? '')); $apiKeyLength = strlen($apiKey); - error_log('[PluginController] Google Books API key length: ' . $apiKeyLength); - $saveResult = $this->pluginManager->setSetting($pluginId, 'google_books_api_key', $apiKey, false); - error_log('[PluginController] Save result: ' . ($saveResult ? 'true' : 'false')); + if (!$this->pluginManager->setSetting($pluginId, 'google_books_api_key', $apiKey, false)) { + \App\Support\SecureLogger::error('[PluginController] Google Books API key not saved for plugin ' . $pluginId); + $response->getBody()->write(json_encode([ + 'success' => false, + 'message' => __('Errore nel salvataggio delle impostazioni.'), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(500); + } $response->getBody()->write(json_encode([ 'success' => true, @@ -538,7 +536,6 @@ public function updateSettings(Request $request, Response $response, array $args return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } - error_log('[PluginController] Settings saved successfully'); return $response->withHeader('Content-Type', 'application/json'); } diff --git a/app/Controllers/SearchController.php b/app/Controllers/SearchController.php index 76186b977..d9d06208b 100644 --- a/app/Controllers/SearchController.php +++ b/app/Controllers/SearchController.php @@ -266,6 +266,9 @@ public function unifiedSearch(Request $request, Response $response, mysqli $db): // emeroteca then links articles to their edit form and adds the // periodicals (#453). Same role gate as the wanted titles above. $results = \App\Support\Hooks::apply('search.unified.sources', $results, [$q, $isOperator ? 'admin' : 'public']); + // One list of records, best title match first, then alphabetical + // (#463), instead of the books block followed by the articles. + $results = is_array($results) ? \App\Support\QuickSearchOrder::apply($results, $q, null, 20) : []; // Note: User search is excluded from frontend unified search to keep admin data separate. } diff --git a/app/Controllers/ThemeController.php b/app/Controllers/ThemeController.php index 540edf36b..106ed292a 100644 --- a/app/Controllers/ThemeController.php +++ b/app/Controllers/ThemeController.php @@ -34,7 +34,7 @@ public function index(Request $request, Response $response): Response $themes = $this->themeManager->getAllThemes(); $activeTheme = $this->themeManager->getActiveTheme(); - $activeLayoutVariant = $this->themeManager->getLayoutVariant($activeTheme); + $publicStyle = $this->themeManager->getPublicStyle($activeTheme); $pageTitle = __('Gestione Temi'); // Render view @@ -73,7 +73,7 @@ public function customize(Request $request, Response $response, array $args): Re $settings = json_decode($theme['settings'], true) ?? []; $colors = $settings['colors'] ?? []; $advanced = $settings['advanced'] ?? []; - $layoutVariant = $this->themeManager->getLayoutVariant($theme); + $publicStyle = $this->themeManager->getPublicStyle($theme); $pageTitle = __('Personalizza Tema') . ': ' . $theme['name']; // Render view @@ -149,8 +149,8 @@ public function save(Request $request, Response $response, array $args): Respons } } - $layoutVariant = $parsedBody['layout_variant'] ?? ThemeManager::DEFAULT_LAYOUT_VARIANT; - if (!is_string($layoutVariant) || !in_array($layoutVariant, ThemeManager::LAYOUT_VARIANTS, true)) { + $publicStyle = $this->postedPublicStyle($parsedBody); + if ($publicStyle === null) { $_SESSION['error'] = __('Stile interfaccia non valido'); return $response ->withHeader('Location', url('/admin/themes/' . $themeId . '/customize')) @@ -158,7 +158,7 @@ public function save(Request $request, Response $response, array $args): Respons } // Build the optional advanced block before writing anything. Colors, - // layout and CSS are then persisted by ThemeManager in one JSON update, + // public style and CSS are then persisted by ThemeManager in one JSON update, // so a failure cannot leave a partially-saved customization. $advanced = null; if (isset($parsedBody['advanced']) && is_array($parsedBody['advanced'])) { @@ -177,7 +177,7 @@ public function save(Request $request, Response $response, array $args): Respons $advanced['custom_css'] = \App\Support\ContentSanitizer::sanitizeCustomCss($advanced['custom_css']); } - $success = $this->themeManager->updateThemeColors($themeId, $colors, $layoutVariant, $advanced); + $success = $this->themeManager->updateThemeColors($themeId, $colors, $publicStyle, $advanced); if ($success) { $_SESSION['success'] = __('Tema salvato con successo'); @@ -191,12 +191,12 @@ public function save(Request $request, Response $response, array $args): Respons } /** - * Save only the active theme's public layout from the themes overview. + * Save only the active theme's public style (hero, cards) from the themes overview. */ public function saveLayout(Request $request, Response $response, array $args): Response { // Check authorization — AdminAuthMiddleware also admits 'staff', but - // changing the site-wide public layout is admin-only (matches index()/customize()). + // changing the site-wide public style is admin-only (matches index()/customize()). if (!isset($_SESSION['user']) || $_SESSION['user']['tipo_utente'] !== 'admin') { return $response->withHeader('Location', url('/admin/dashboard'))->withStatus(302); } @@ -204,15 +204,13 @@ public function saveLayout(Request $request, Response $response, array $args): R $themeId = (int) ($args['id'] ?? 0); $theme = $this->themeManager->getThemeById($themeId); $parsedBody = $request->getParsedBody(); - $layoutVariant = is_array($parsedBody) && is_string($parsedBody['layout_variant'] ?? null) - ? $parsedBody['layout_variant'] - : ''; + $publicStyle = is_array($parsedBody) ? $this->postedPublicStyle($parsedBody) : null; if (!$theme || empty($theme['active'])) { $_SESSION['error'] = __('Tema non trovato'); - } elseif (!in_array($layoutVariant, ThemeManager::LAYOUT_VARIANTS, true)) { + } elseif ($publicStyle === null) { $_SESSION['error'] = __('Stile interfaccia non valido'); - } elseif ($this->themeManager->updateLayoutVariant($themeId, $layoutVariant)) { + } elseif ($this->themeManager->updatePublicStyle($themeId, $publicStyle)) { $_SESSION['success'] = __('Tema salvato con successo'); } else { $_SESSION['error'] = __('Errore nel salvataggio del tema'); @@ -223,6 +221,22 @@ public function saveLayout(Request $request, Response $response, array $args): R ->withStatus(302); } + /** + * The hero and card styles a form posted, or null when either is not one + * of ThemeManager::HERO_STYLES / CARD_STYLES. + * + * @param array $parsedBody + * @return array{hero_style:string,card_style:string}|null + */ + private function postedPublicStyle(array $parsedBody): ?array + { + $style = [ + 'hero_style' => is_string($parsedBody['hero_style'] ?? null) ? $parsedBody['hero_style'] : '', + 'card_style' => is_string($parsedBody['card_style'] ?? null) ? $parsedBody['card_style'] : '', + ]; + return ThemeManager::isValidPublicStyle($style) ? $style : null; + } + /** * Activate a theme */ diff --git a/app/Controllers/UserWishlistController.php b/app/Controllers/UserWishlistController.php index 968b192a2..835340ed8 100644 --- a/app/Controllers/UserWishlistController.php +++ b/app/Controllers/UserWishlistController.php @@ -21,8 +21,14 @@ public function page(Request $request, Response $response, mysqli $db, mixed $co // A favourite is HIDDEN, never deleted, while the book is flagged as a // request: manage('received') clears the flag when the donation arrives, // and the entry — plus its availability notification — must come back. - $sql = "SELECT l.id, l.titolo, l.copertina_url, l.copie_disponibili - FROM wishlist w JOIN libri l ON l.id=w.libro_id + // The main author (a translator or editor is never shown as one): shown on the card, and what book_url() needs for the + // canonical /author/title/id address (without it the slug fell back to "autore"). + $sql = "SELECT l.id, l.titolo, l.copertina_url, l.copie_disponibili, e.nome AS editore, + (SELECT a.nome FROM libri_autori la JOIN autori a ON a.id = la.autore_id + WHERE la.libro_id = l.id AND la.ruolo IN ('principale','co-autore') + ORDER BY la.ruolo = 'principale' DESC, la.ordine_credito IS NULL, la.ordine_credito, a.id + LIMIT 1) AS autore + FROM wishlist w JOIN libri l ON l.id=w.libro_id LEFT JOIN editori e ON e.id = l.editore_id WHERE w.utente_id=? AND l.deleted_at IS NULL AND " . \App\Support\BookVisibility::catalogue($db, 'l') . " ORDER BY w.id DESC"; diff --git a/app/Support/CmsImageStorage.php b/app/Support/CmsImageStorage.php new file mode 100644 index 000000000..d1889d21a --- /dev/null +++ b/app/Support/CmsImageStorage.php @@ -0,0 +1,64 @@ +, i.e. from public/uploads/cms. Older versions wrote the + * file to storage/uploads/cms, outside the web root, while still storing the + * /uploads/cms/ URL: the image answered 404 everywhere, in the admin + * preview and on the public page. ensurePublic() copies such a file to the + * place its URL points to, the first time the page is edited or viewed. + */ +final class CmsImageStorage +{ + public const URL_PREFIX = '/uploads/cms/'; + + public static function publicDir(): string + { + return dirname(__DIR__, 2) . '/public/uploads/cms'; + } + + private static function legacyDir(): string + { + return dirname(__DIR__, 2) . '/storage/uploads/cms'; + } + + /** + * Make sure the file behind a stored /uploads/cms/ URL is served: + * when it exists only in the legacy storage folder, copy it into the + * public one. Anything else (empty, external URL, other folders) is left + * alone. Never throws: an image is never worth a broken page. + */ + public static function ensurePublic(?string $url): void + { + if ($url === null || !str_starts_with($url, self::URL_PREFIX)) { + return; + } + $name = basename($url); + // Only the names the uploader generates: no traversal, no surprises. + if (!preg_match('/^cms_[a-f0-9]{32}\.(?:jpe?g|png|gif|webp)$/D', $name)) { + return; + } + $target = self::publicDir() . '/' . $name; + $source = self::legacyDir() . '/' . $name; + try { + if (is_file($target) || !is_file($source)) { + return; + } + if (!is_dir(self::publicDir()) && !@mkdir(self::publicDir(), 0755, true) && !is_dir(self::publicDir())) { + return; + } + if (@copy($source, $target)) { + @chmod($target, 0644); + } + } catch (\Throwable $e) { + SecureLogger::warning('[CMS] could not move a legacy page image to public/uploads/cms: ' . $e->getMessage()); + } + } +} diff --git a/app/Support/ConfigStore.php b/app/Support/ConfigStore.php index 7ee37d647..1bb017ed0 100644 --- a/app/Support/ConfigStore.php +++ b/app/Support/ConfigStore.php @@ -132,6 +132,10 @@ public static function all(): array ], 'cms' => [ 'events_page_enabled' => '1', // Default to enabled + // Plugin sections listed in the public menu (the pages stay + // reachable: catalogue and search results link to them). + 'emeroteca_in_menu' => '1', + 'archives_in_menu' => '1', ], 'sharing' => [ 'enabled_providers' => 'facebook,x,whatsapp,email', @@ -325,6 +329,15 @@ public static function isCatalogueMode(): bool return (bool) self::get('system.catalogue_mode', false); } + /** + * Whether a plugin section ('emeroteca', 'archives') has its entry in the + * public menu. Only the menu entry: the section's pages stay reachable. + */ + public static function isInPublicMenu(string $section): bool + { + return (string) self::get("cms.{$section}_in_menu", '1') === '1'; + } + private static function mergeRecursiveDistinct(array $base, array $replacements): array { foreach ($replacements as $key => $value) { diff --git a/app/Support/ContentCache.php b/app/Support/ContentCache.php index 35384d5e9..63833ab79 100644 --- a/app/Support/ContentCache.php +++ b/app/Support/ContentCache.php @@ -18,7 +18,7 @@ final class ContentCache /** * Book metadata or taxonomy changed: invalidate catalog counts/facets, - * every home entry (home_page_data_v1 and home_api_count_*), the cached + * every home entry (home_page_data_v2 and home_api_count_*), the cached * genre tree and static detail DTOs. Availability-only writes use the * narrower availabilityChanged() path below. */ diff --git a/app/Support/ContentSanitizer.php b/app/Support/ContentSanitizer.php index 942869c1a..c0a94ba0d 100644 --- a/app/Support/ContentSanitizer.php +++ b/app/Support/ContentSanitizer.php @@ -55,11 +55,17 @@ public static function sanitizeCustomCss(string $css): string // Rimuove ogni apertura/chiusura di `, + // `ipt>` → ` diff --git a/app/Views/frontend/catalog.php b/app/Views/frontend/catalog.php index dbf81f88f..1bc660319 100644 --- a/app/Views/frontend/catalog.php +++ b/app/Views/frontend/catalog.php @@ -98,31 +98,30 @@ ?> -
-
-
-

-

- +
+
+ +
+
+

+

+
-
-
-
+
+
+
-
+
-
+ -
+
-
+
+ +
@@ -435,7 +460,7 @@ class="year-slider"
-
+
@@ -457,37 +482,9 @@ class="year-slider"
- - - htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); ?> -
-

- - -

- +
+
-
-
-
-
+
+
+

-

- -

- +

+
+
+ +
diff --git a/app/Views/frontend/home-sections/features_title.php b/app/Views/frontend/home-sections/features_title.php index 4b8c5c8a8..d7477fffc 100644 --- a/app/Views/frontend/home-sections/features_title.php +++ b/app/Views/frontend/home-sections/features_title.php @@ -7,46 +7,40 @@ ?> -
-
-

-

- -

- +
+

+

+
+ + +
+ $feature): + $icon = $feature['content'] ?? 'fas fa-star'; + $title = $feature['title'] ?? ''; + $desc = $feature['subtitle'] ?? ''; ?> - -
- -
-
-
- -
-

-
-

- -

+
+
+ +
- +

+

- +
+
diff --git a/app/Views/frontend/home-sections/hero.php b/app/Views/frontend/home-sections/hero.php index f88000344..f0b51cf73 100644 --- a/app/Views/frontend/home-sections/hero.php +++ b/app/Views/frontend/home-sections/hero.php @@ -1,7 +1,10 @@ +$heroTitle = trim((string)($heroData['title'] ?? '')) !== '' ? (string) $heroData['title'] : __("La Tua Biblioteca Digitale"); +$heroSubtitle = trim((string)($heroData['subtitle'] ?? '')) !== '' ? (string) $heroData['subtitle'] : __("Scopri, prenota e gestisci i tuoi libri preferiti con la nostra piattaforma elegante e moderna."); +$heroCovers = $heroCovers ?? []; +$e = static fn (string $v): string => htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); - - "La tua biblioteca digitale". +$heroWords = preg_split('/\s+/u', trim($heroTitle)) ?: []; +$heroTitleHtml = count($heroWords) > 1 + ? $e(implode(' ', array_slice($heroWords, 0, -1))) . ' ' . $e((string) end($heroWords)) . '' + : $e($heroTitle); + +// Counters are precomputed (and cached) server-side by FrontendController:: +// home(); the client-side loadStats() fetch only runs as a fallback when the +// values are missing (data-server-rendered absent). +$heroStatsServerRendered = isset($heroTotalBooks, $heroAvailableBooks); +$edgeCacheEnabled = \App\Support\LiteSpeedCache::enabled(); +// Thousands grouped as the visitor's language writes them (1.234 / 1,234 / 1 234). +$heroLocale = strtolower(substr(\App\Support\I18n::getLocale(), 0, 2)); +$heroThousands = match ($heroLocale) { + 'en' => ',', + 'fr' => "\u{202F}", + default => '.', +}; +$heroNumber = static fn (int $n): string => number_format($n, 0, ',', $heroThousands); +$spinner = '' . $e(__("Caricamento...")) . ''; ?> -
-
-
-

-

- -

+
+
+
+ +
+ +

+

-
-
-
- - " - aria-label=""> - + + - - -
- -
-
- > - - - -
- -
- -
- -
-
- > - -
- -
- - - -
- -
- -
- -
-
- 12 - -
-
- 24/7 - -
+ +
+ + +
+ + + + + <?= $e($coverTitle) ?> + + + +
+ +
+
+ +
+
+
+ > + +
+
+ > + +
+ +
+ + +
+ +
+ 24/7 + +
diff --git a/app/Views/frontend/home-sections/latest_books_title.php b/app/Views/frontend/home-sections/latest_books_title.php index a6ab732f0..3736bd646 100644 --- a/app/Views/frontend/home-sections/latest_books_title.php +++ b/app/Views/frontend/home-sections/latest_books_title.php @@ -16,33 +16,28 @@ ?> -
-
-

-

- -

-
> - - - -
-
- -
-

-
- +
+
+
+
+

+

-
- - - - - + +
+
> + + + +
+
+ +
+

+ +
+
+
diff --git a/app/Views/frontend/home-sections/text_content.php b/app/Views/frontend/home-sections/text_content.php index 50b9d78f0..08be0db10 100644 --- a/app/Views/frontend/home-sections/text_content.php +++ b/app/Views/frontend/home-sections/text_content.php @@ -1,19 +1,45 @@ htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); +$textTitle = trim((string) ($textData['title'] ?? '')); +$textBody = \App\Support\HtmlHelper::sanitizeHtml($textData['content'] ?? ''); +$textEyebrow = ''; +$textBig = $textTitle; +if (preg_match('/^(.+?)\s+[-–—]\s+(.+)$/u', $textTitle, $m)) { + $textBig = trim((string) preg_replace('/\s*\([^)]*\)\s*$/u', '', $m[1])); + $textEyebrow = trim($m[2]); +} +$textFigure = null; +$textPlain = html_entity_decode(strip_tags($textBody), ENT_QUOTES, 'UTF-8'); +if (preg_match('/tradi(?:zione|tion)[^.!?]{0,80}?(\d{1,3}(?:[.,\x{00A0}\x{202F} ]\d{3})+|\d{3,})\s+(?:anni|years|Jahren?|ans|år)(?![\p{L}])/iu', $textPlain, $f)) { + $textFigure = $f[1]; +} +$textIsWord = $textBig !== '' && mb_strlen($textBig) <= 16; ?> -
-
- -

- -
- +
+
+ +
+ + +

+ +
+
+ +
diff --git a/app/Views/frontend/home.php b/app/Views/frontend/home.php index a22e595b2..8407e9ff4 100644 --- a/app/Views/frontend/home.php +++ b/app/Views/frontend/home.php @@ -12,18 +12,15 @@ // markup with no CSS of its own: link the catalogue stylesheet for it. $catalogPageStyles = true; -// Preload the hero background: it's the LCP element of the home page and, -// being a CSS background, the browser only discovers it after CSS parsing. -// Only when the hero section is actually active. -if (isset($homeContent['hero'])) { - $heroPreloadImage = ($homeContent['hero']['background_image'] ?? '') !== '' - ? url($homeContent['hero']['background_image']) - : assetUrl('books.jpg'); +// Preload the first cover of the hero fan: it is the largest image above the +// fold, so the LCP element of the home page. Only when the hero is active. +$heroCovers = $heroCovers ?? []; +if (isset($homeContent['hero']) && $heroCovers !== [] && !empty($heroCovers[0]['copertina_url'])) { $headLinks = $headLinks ?? []; $headLinks[] = [ 'rel' => 'preload', 'as' => 'image', - 'href' => $heroPreloadImage, + 'href' => absoluteUrl((string) $heroCovers[0]['copertina_url']), 'fetchpriority' => 'high', ]; } @@ -31,386 +28,8 @@ // SEO Variables are now passed from FrontendController::home() // No need to override them here - the controller handles all SEO logic with proper fallbacks $additional_css = " - /* Keep the configurable background photo (injected inline in hero.php). - Do NOT set a background here — that would erase the setting. */ - .hero-section { - color: #ffffff; - padding: 8rem 0 6rem; - position: relative; - min-height: 100vh; - display: flex; - align-items: center; - overflow: hidden; - } - - /* Elegant legibility gradient over the configurable photo. Neutral-dark so - the display type reads; the theme colour lives in the accents below, not - as a heavy wash. Bottom-anchored so the search sits on the darkest area. */ - .hero-section::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(180deg, - rgba(9, 11, 18, 0.30) 0%, - rgba(9, 11, 18, 0.34) 42%, - rgba(9, 11, 18, 0.70) 100%); - z-index: 1; - pointer-events: none; - } - - .hero-content { - position: relative; - z-index: 2; - text-align: center; - } - - @keyframes heroUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } } - - .hero-title { - font-family: var(--serif) !important; - font-size: clamp(2.8rem, 7vw, 5.6rem) !important; - font-weight: 370 !important; - letter-spacing: -0.035em !important; - line-height: 1.02; - margin-bottom: 1.5rem; - color: #ffffff !important; - text-shadow: none; - animation: heroUp .9s cubic-bezier(.22, 1, .36, 1) both; - } - - .hero-subtitle { - font-size: clamp(1.1rem, 1.8vw, 1.45rem); - font-weight: 400; - opacity: 0.94; - margin: 0 auto 2.5rem; - max-width: 46ch; - line-height: 1.5; - color: #ffffff !important; - text-shadow: none; - animation: heroUp .9s cubic-bezier(.22, 1, .36, 1) .08s both; - } - - .hero-stats { - display: flex; - gap: clamp(1.5rem, 4vw, 3.5rem); - margin-top: 3rem; - justify-content: center; - flex-wrap: wrap; - animation: heroUp .9s cubic-bezier(.22, 1, .36, 1) .22s both; - } - - /* Stats: naked numbers, no boxes. A thin rule separates them. */ - .hero-stat { - text-align: center; - padding: 0 clamp(1rem, 2.5vw, 2rem); - background: none; - border: none; - border-left: 1px solid rgba(255, 255, 255, 0.28); - border-radius: 0; - transition: none; - } - .hero-stat:first-child { border-left: none; } - .hero-stat:hover { transform: none; background: none; } - - .hero-stat-number { - font-family: var(--serif); - font-size: clamp(2.2rem, 3.6vw, 3rem); - font-weight: 420; - display: block; - margin-bottom: 0.25rem; - letter-spacing: -0.03em; - color: #ffffff !important; - text-shadow: none; - font-variant-numeric: tabular-nums; - } - - .hero-stat-label { - font-size: 0.72rem; - opacity: 0.82; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.14em; - color: #ffffff !important; - text-shadow: none; - } - - .section { - padding: 6rem 0; - } - - /* Remove bottom padding from last section (genre carousels) to avoid gap before footer */ - section#genre-carousels { - padding-bottom: 0; - } - - .section-alt { - background: var(--light-bg); - } - - .section-title { - text-align: center; - margin-bottom: 1rem; - font-size: 3rem; - font-weight: 800; - color: var(--text-color); - letter-spacing: -0.03em; - line-height: 1.2; - } - - .section-subtitle { - text-align: center; - font-size: 1.2rem; - color: var(--text-light); - margin-bottom: 3rem; - max-width: 600px; - margin-left: auto; - margin-right: auto; - font-weight: 400; - } - - .feature-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: clamp(1.5rem, 3vw, 2.75rem); - margin-top: 3.5rem; - } - - /* Features: open editorial columns, left-aligned, one hairline on top. - No boxes, no filled circles. Icons kept (they're configurable) but shown - small in the theme accent, not as heavy filled squares. */ - .feature-card { - text-align: left; - padding: 1.75rem 0 0; - background: none; - border: none; - border-top: 1px solid var(--border-color); - border-radius: 0; - box-shadow: none !important; - transition: none; - position: relative; - overflow: visible; - } - - .feature-card:hover { - transform: none; - box-shadow: none !important; - border-color: var(--primary-color); - } - - .feature-icon { - width: auto; - height: auto; - margin: 0 0 1.1rem; - background: none !important; - border-radius: 0; - display: block; - font-size: 1.5rem; - color: var(--primary-color) !important; - box-shadow: none; - } - - .feature-heading { - display: block; - } - - .feature-title { - font-family: var(--serif); - font-size: 1.3rem; - font-weight: 460; - color: var(--text-color); - margin-bottom: 0.6rem; - letter-spacing: -0.02em; - } - - .feature-description { - color: var(--text-light); - line-height: 1.6; - font-size: 1rem; - } - - .cta-section { - background: var(--light-bg); - color: var(--text-color); - padding: 6rem 0; - text-align: center; - position: relative; - overflow: hidden; - border-top: 1px solid var(--border-color); - } - - .cta-section::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: url('data:image/svg+xml,'); - } - - .cta-content { - position: relative; - z-index: 2; - } - - .cta-title { - font-size: 3rem; - font-weight: 800; - margin-bottom: 1.5rem; - letter-spacing: -0.03em; - color: var(--text-color); - } - - .cta-subtitle { - font-size: 1.3rem; - margin-bottom: 3rem; - opacity: 0.8; - font-weight: 400; - max-width: 500px; - margin-left: auto; - margin-right: auto; - color: var(--text-light); - } - - /* Hero Search Styles */ - .hero-search-container { - max-width: 1200px; - margin: 0 auto 4rem; - } - - .hero-search-form { - margin-bottom: 3rem; - position: relative; - } -form.hero-search-form { - max-width: 90%; - margin: auto; -} - input.hero-search-input.search-input { - box-shadow: none; -} - .hero-search-input-group { - position: relative; - display: flex; - align-items: center; - background: rgba(255, 255, 255, 0.95); - border-radius: 2px; - padding: 0.75rem 1.5rem; - box-shadow: none; - backdrop-filter: blur(10px); - transition: all 0.3s ease; - } - - .hero-search-input-group:focus-within { - background: white; - box-shadow: none; - transform: translateY(-2px); - } - - .hero-search-icon { - color: var(--primary-color); - font-size: 1.125rem; - margin-right: 1rem; - opacity: 0.7; - } - - .hero-search-input { - flex: 1; - border: none; - background: transparent; - font-size: 1.125rem; - color: var(--text-color); - font-weight: 500; - outline: none; - padding: 0.5rem 0; - } - - .hero-search-input:focus { - border: none; - outline: none; - box-shadow: none; - } - - .hero-search-input::placeholder { - color: rgba(44, 62, 80, 0.6); - font-weight: 400; - } - - /* Override browser autofill background */ - .hero-search-input:-webkit-autofill, - .hero-search-input:-webkit-autofill:hover, - .hero-search-input:-webkit-autofill:focus, - .hero-search-input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 30px white inset !important; - -webkit-text-fill-color: var(--text-color) !important; - background-color: transparent !important; - transition: background-color 5000s ease-in-out 0s; - } - - .hero-search-button { - background: var(--button-color); - color: var(--button-text-color); - border: none; - padding: 0.75rem 1.5rem; - border-radius: 2px; - font-weight: 600; - font-size: 0.875rem; - letter-spacing: 0.025em; - transition: all 0.3s ease; - margin-left: 1rem; - min-height: 44px; - } - - .hero-search-button:hover { - background: var(--button-hover); - color: var(--button-text-color); - transform: translateY(-1px); - box-shadow: none; - } - - .hero-quick-links { - display: flex; - justify-content: center; - gap: 2rem; - flex-wrap: wrap; - margin-top: 2rem; /* added spacing between search bar and quick links */ - } - - .hero-quick-link { - display: inline-flex; - align-items: center; - gap: 0.55rem; - color: #ffffff !important; - text-decoration: none; - font-weight: 600; - font-size: 0.8rem; - letter-spacing: 0.06em; - text-transform: uppercase; - transition: opacity 0.3s ease; - padding: 0.5rem 0; - border-radius: 0; - background: none; - backdrop-filter: none; - border: none; - border-bottom: 1px solid rgba(255, 255, 255, 0.45); - opacity: 0.92; - } - - .hero-quick-link:hover { - color: #ffffff !important; - background: none; - transform: none; - text-decoration: none; - border-color: #ffffff; - box-shadow: none; - opacity: 1; - } - - .hero-quick-link i { - font-size: 0.7rem; - opacity: 0.9; - } - + /* Hero, counters, sections, features and the call to action are styled + by public/assets/pinakes-2026.css (2026 design). */ .loading-placeholder { text-align: center; padding: 4rem 2rem; @@ -429,134 +48,14 @@ @keyframes loadingRingSpin { to { transform: rotate(360deg); } } - /* Responsive adjustments */ - /* Tablet: 2 columns */ - @media (max-width: 1024px) { - .feature-grid { - grid-template-columns: repeat(2, 1fr); - gap: 2.5rem; - } - } - - /* Mobile: 1 column */ - @media (max-width: 768px) { - .hero-section { - padding: 6rem 0 4rem; - min-height: 85vh; - background-attachment: scroll; - } - - .hero-title { - font-size: 2.8rem; - } - - .hero-subtitle { - font-size: 1.2rem; - } - - .section-title { - font-size: 2.2rem; - } - - .cta-title { - font-size: 2.2rem; - } - - .feature-grid { - grid-template-columns: 1fr; - gap: 2rem; - } - - .hero-stats { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1.5rem 0; - width: 100%; - } - - .hero-stat { - min-width: 0; - padding: 0 0.75rem; - border-left: 0; - } - - .hero-stat:nth-child(even) { - border-left: 1px solid rgba(255, 255, 255, 0.28); - } - - .feature-heading { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.65rem; - } - - .feature-heading .feature-icon { - flex: 0 0 auto; - margin: 0; - font-size: 1.25rem; - line-height: 1; - } - - .feature-heading .feature-title { - margin: 0; - } - } - - @media (max-width: 480px) { - .hero-title { - font-size: 2.2rem; - } - - .section-title { - font-size: 1.8rem; - } - - .cta-title { - font-size: 1.8rem; - } - - .hero-stats { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1.25rem 0; - } - - .hero-search-container { - max-width: 100%; - margin-bottom: 3rem; - } - - .hero-search-form { - margin-bottom: 2rem; - } - - .hero-search-input-group { - padding: 0.625rem 1.25rem; - } - - .hero-search-input { - font-size: 1rem; - } - - .hero-search-button { - padding: 0.625rem 1.25rem; - font-size: 0.75rem; - } - - .hero-quick-links { - gap: 1rem; - } - - .hero-quick-link { - font-size: 0.75rem; - padding: 0.375rem 0.75rem; - min-height: 44px; - } - } h6.search-section-title { text-align: left; } + section#genre-carousels { + padding-bottom: 0; + } + /* Genre Carousel Styles */ .genre-carousel-section { padding: 4rem 0; @@ -595,7 +94,7 @@ gap: 0.4rem; font-size: 0.95rem; font-weight: 600; - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); text-decoration: none; transition: opacity 0.2s ease; } @@ -923,7 +422,7 @@ } .home-events-grid .event-card__title a:hover { - color: var(--primary-color, #d70161); + color: var(--primary-text, var(--primary-color, #d70161)); } .home-events-grid .event-card__meta { diff --git a/app/Views/frontend/layout.php b/app/Views/frontend/layout.php index 7a20225d4..6622dd9d2 100644 --- a/app/Views/frontend/layout.php +++ b/app/Views/frontend/layout.php @@ -26,6 +26,7 @@ $reservationsRoute = route_path('reservations'); $wishlistRoute = route_path('wishlist'); $profileRoute = route_path('profile'); +$userDashboardRoute = route_path('user_dashboard'); $loginRoute = route_path('login'); $registerRoute = route_path('register'); @@ -36,8 +37,6 @@ $versionFile = __DIR__ . '/../../../version.json'; $versionData = file_exists($versionFile) ? json_decode(file_get_contents($versionFile), true) : null; $appVersion = $versionData['version'] ?? '0.1.0'; -$frontendLayoutsMtime = @filemtime(dirname(__DIR__, 3) . '/public/assets/frontend-layouts.css'); -$frontendLayoutsVersion = $frontendLayoutsMtime !== false ? (string)$frontendLayoutsMtime : $appVersion; $frontendMainMtime = @filemtime(dirname(__DIR__, 3) . '/public/assets/main.css'); $frontendMainVersion = $frontendMainMtime !== false ? (string)$frontendMainMtime : $appVersion; $frontendVendorMtime = @filemtime(dirname(__DIR__, 3) . '/public/assets/vendor.css'); @@ -56,6 +55,10 @@ $catalogPagesVersion = $catalogPagesMtime !== false ? (string)$catalogPagesMtime : $appVersion; $bookDetailMtime = @filemtime(dirname(__DIR__, 3) . '/public/assets/book-detail.css'); $bookDetailVersion = $bookDetailMtime !== false ? (string)$bookDetailMtime : $appVersion; +// The stylesheet and the script share one version: whichever changed last, so +// an edit to the script alone still reaches browsers that cached the old one. +$pinakes2026Mtime = max((int) @filemtime(dirname(__DIR__, 3) . '/public/assets/pinakes-2026.css'), (int) @filemtime(dirname(__DIR__, 3) . '/public/assets/pinakes-2026.js')); +$pinakes2026Version = $pinakes2026Mtime > 0 ? (string)$pinakes2026Mtime : $appVersion; // Load theme colors if (isset($container)) { @@ -64,23 +67,23 @@ $activeTheme = $themeManager->getActiveTheme(); $themeColors = $themeManager->getThemeColors($activeTheme); $themePalette = $themeColorizer->generateColorPalette($themeColors); - $layoutVariant = $themeManager->getLayoutVariant($activeTheme); + $publicStyle = $themeManager->getPublicStyle($activeTheme); } elseif (isset($db) && $db instanceof mysqli) { // Public views rendered by standalone controllers or plugins do not always // receive the DI container, but they do share the request's DB handle. // Resolve the same active theme from that handle so CMS/contact/plugin - // pages never silently fall back to Editoriale. + // pages never silently fall back to the default palette and style. $themeManager = new \App\Support\ThemeManager($db); $themeColorizer = new \App\Support\ThemeColorizer(); $activeTheme = $themeManager->getActiveTheme(); $themeColors = $themeManager->getThemeColors($activeTheme); $themePalette = $themeColorizer->generateColorPalette($themeColors); - $layoutVariant = $themeManager->getLayoutVariant($activeTheme); + $publicStyle = $themeManager->getPublicStyle($activeTheme); } else { // Fallback colors when container is not available $themePalette = [ 'primary' => '#d70161', - 'secondary' => '#111827', + 'secondary' => '#1b1720', 'button' => '#d70262', 'button_text' => '#ffffff', 'primary_light' => '#f9e6ef', @@ -89,10 +92,14 @@ 'primary_focus' => '#b70152', 'secondary_hover' => '#0f1623', 'button_hover' => '#c20258', + 'primary_text' => '#ce015d', + 'button_surface' => '#d70262', + 'secondary_surface' => '#1b1720', + 'primary_on_dark' => '#e2488d', 'primary_rgb' => '215, 1, 97', 'button_rgb' => '215, 2, 98', ]; - $layoutVariant = \App\Support\ThemeManager::DEFAULT_LAYOUT_VARIANT; + $publicStyle = ['hero_style' => \App\Support\ThemeManager::DEFAULT_HERO_STYLE, 'card_style' => \App\Support\ThemeManager::DEFAULT_CARD_STYLE]; } // Get events page status using ConfigStore (has its own DB connection) @@ -156,7 +163,13 @@ } try { if (!$archivesAvailable && $publicPluginIsActive('archives') && $publicNavigationDb instanceof mysqli) { - $unitCheck = $publicNavigationDb->query("SELECT 1 FROM archival_units WHERE deleted_at IS NULL LIMIT 1"); + // Only a PUBLISHED unit makes the archive worth a menu entry; an + // Archives older than the publication flag has every unit public. + try { + $unitCheck = $publicNavigationDb->query("SELECT 1 FROM archival_units WHERE deleted_at IS NULL AND published = 1 LIMIT 1"); + } catch (\mysqli_sql_exception $e) { + $unitCheck = $publicNavigationDb->query("SELECT 1 FROM archival_units WHERE deleted_at IS NULL LIMIT 1"); + } if ($unitCheck instanceof mysqli_result && $unitCheck->num_rows === 1) { $archivesAvailable = true; } @@ -311,11 +324,13 @@ - + + + ; + /* The accent as text: darkened only as far as AA contrast needs + (--primary-text on the soft tint, --primary-ink on the page). */ + --primary-text: + + ; + --primary-ink: + + ; + /* Filled surfaces kept at AA with their text, and the accent as + text on the dark surface (ThemeColorizer::readableSurface()). */ + --button-surface: + + ; + --secondary-surface: + + ; + --primary-on-dark: + + ; --secondary-color: ; @@ -436,7 +470,7 @@ .header-brand { font-size: 1.5rem; font-weight: 800; - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); text-decoration: none; display: flex; align-items: center; @@ -496,7 +530,7 @@ .nav-links a:hover, .nav-links a.active { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); font-weight: 600; } @@ -569,7 +603,7 @@ } .mobile-search-toggle:hover { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } /* Mobile search container animation */ @@ -792,7 +826,7 @@ .btn-outline-header:hover { border-color: var(--primary-color); - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); background: rgba(0, 0, 0, 0.02); transform: translateY(-1px); } @@ -977,7 +1011,7 @@ } .mobile-menu-toggle:hover { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } .header-content { @@ -1113,7 +1147,7 @@ font-weight: 700; line-height: 1.4; margin-bottom: 0.75rem; - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); letter-spacing: -0.01em; } @@ -1373,6 +1407,9 @@ opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; + /* The closed drawer waits off-screen to the right: clip it here, so + it never widens the page (a phone could pan to it otherwise). */ + overflow: hidden; } .mobile-menu-overlay.active { @@ -1381,7 +1418,7 @@ } .mobile-menu-content { - position: fixed; + position: absolute; top: 0; right: 0; width: 80%; @@ -1430,7 +1467,7 @@ } .mobile-menu-close:hover { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } .mobile-nav { @@ -1450,12 +1487,12 @@ .mobile-nav-link:hover { background: rgba(0, 0, 0, 0.05); - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } .mobile-nav-link.active { background: color-mix(in srgb, var(--primary-color) 10%, transparent); - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); border-left: 3px solid var(--primary-color); } @@ -1470,114 +1507,6 @@ border-top: 1px solid var(--border-color); } - /* ========================================================== - PINAKES EDITORIAL — bold restyle (branch design/modern-frontend) - Shared system across home, catalog, book. Colours + theme - variables untouched; --primary-color is used BOLDLY as a - committed surface. Self-hosted fonts. Crisp, minimal, few - borders. Scoped to .main-content / frontend body. - ========================================================== */ - :root{ - --serif:'Fraunces', Georgia, 'Times New Roman', serif; - --sans:'Instrument Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - --radius-sm:2px; --radius-md:2px; --radius-lg:2px; --radius-xl:3px; - --card-shadow:none; - --card-shadow-hover:0 1px 2px rgba(15,23,42,.05); - --ink:var(--text-color); - --edge:var(--border-color); - } - body{ font-family:var(--sans); letter-spacing:-.008em; } - body, main, .main-content{ background:var(--white); } - - /* Fraunces display voice — light, elegant, big. */ - h1,h2,h3,h4, - .hero-title,.section-title,.page-title,.book-title,.book-title-hero, - .book-detail-title,.event-title,.cms-title,.page-hero__title, - .related-book-title,.feature-title,.cta-title,.genre-carousel-title, - .home-events__title{ - font-family:var(--serif) !important; - font-weight:420 !important; - letter-spacing:-.025em !important; - line-height:1.05; - } - .header-brand,.header-brand .brand-text{ font-family:var(--serif) !important; font-weight:560 !important; letter-spacing:-.03em !important; } - - /* Eyebrow / micro-label motif (uppercase, tracked, accent). */ - .plabel, .eyebrow{ font:600 .72rem/1 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--primary-color); } - - /* --- De-round EVERYTHING (no pills) + flatten + de-box --- */ - .main-content input, .main-content select, .main-content textarea, - .search-input,.hero-search-input,.hero-search-input-group, - .btn-cta,.btn-cta-outline,.btn-cta-sm,.btn-cta-lg,.btn-header,.btn-search-mobile, - .ui-button,.btn-primary,.btn-outline-primary,.btn-view,.btn-related-view,.btn-catalog, - .book-card,.book-image-container,.book-status,.book-status-badge,.book-media-badge, - .genre-tag,.keyword-chip,.availability-badge,.author-item,.status-badge,.chip, - .filter-tag,.facet-collapsed,.feature-card,.feature-icon,.related-book-card, - .event-card,.event-cover,.related-card,.page-link,.user-dropdown-menu, - .cover-img,.book-cover-large,.hero-quick-link,.card{ - border-radius:2px !important; - } - .main-content .book-card,.main-content .feature-card,.main-content .related-book-card, - .main-content .event-card,.main-content .card,.main-content .author-info, - .main-content .archive-icon,.book-description-section,.book-details-section, - .book-reviews-section,.book-meta{ - box-shadow:none !important; - backdrop-filter:none !important; - -webkit-backdrop-filter:none !important; - } - /* covers get the only real elevation */ - .book-card .book-image-container, .book-cover-large, .related-book-card .book-image-container{ - box-shadow:0 1px 3px rgba(15,23,42,.10) !important; - } - - /* Buttons: crisp, confident. */ - .btn-cta,.btn-primary,.btn-outline-primary,.btn-view,.btn-catalog,.btn-related-view{ - letter-spacing:.01em; font-weight:600; text-transform:none; - } - - /* ---- KILL THE "OLD" LOOK: no shadows, no gradients-as-decoration, no - glass, everywhere inside the frontend. This is what makes it modern. */ - .main-content [class*="card"], - .main-content [class*="section"], - .main-content [class*="panel"], - .main-content [class*="box"], - .main-content [class*="feature"], - .main-content [class*="stat"], - .main-content [class*="badge"], - .main-content [class*="tag"], - .main-content [class*="tile"], - .main-content .card, .main-content .well{ - box-shadow:none !important; - backdrop-filter:none !important; - -webkit-backdrop-filter:none !important; - text-shadow:none !important; - } - /* covers are the only elevated objects */ - .main-content .book-image-container, - .main-content .book-cover-large, - .main-content .cover-img{ - box-shadow:0 1px 3px rgba(15,23,42,.12) !important; - } - /* generous editorial rhythm */ - .main-content .section, - .main-content section[class*="section"]{ padding-top:clamp(3.5rem,8vw,6rem); padding-bottom:clamp(3.5rem,8vw,6rem); } - - /* Header: clean, crisp hairline, no heavy blur. */ - .header-container{ background:rgba(255,255,255,.94); backdrop-filter:saturate(1.03) blur(6px); -webkit-backdrop-filter:saturate(1.03) blur(6px); } - - /* ---- Elegant motion: fade-up on scroll (opt-in via .reveal, added by - the observer below). Respects reduced-motion. ---- */ - .reveal{ opacity:0; transform:translateY(18px); } - .reveal.is-in{ opacity:1; transform:none; transition:opacity .7s cubic-bezier(.22,1,.36,1), transform .7s cubic-bezier(.22,1,.36,1); } - @media (prefers-reduced-motion: reduce){ - .reveal,.reveal.is-in{ opacity:1 !important; transform:none !important; transition:none !important; } - } - /* elegant hover: cover lift + link underline reveal */ - .main-content .book-card{ transition:transform .5s cubic-bezier(.22,1,.36,1); } - .main-content .book-card:hover{ transform:translateY(-6px); } - .main-content a.book-title-link, .main-content .book-title a{ background-image:linear-gradient(var(--primary-color),var(--primary-color)); background-size:0% 1px; background-position:0 100%; background-repeat:no-repeat; transition:background-size .4s cubic-bezier(.22,1,.36,1); } - .main-content a.book-title-link:hover, .main-content .book-title a:hover{ background-size:100% 1px; } - @@ -1588,25 +1517,13 @@ + + getAdvancedSettings($activeTheme ?? null); - $themeCustomCss = is_string($themeAdvanced['custom_css'] ?? null) - ? ContentSanitizer::sanitizeCustomCss($themeAdvanced['custom_css']) - : ''; - } - if ($themeCustomCss !== ''): - ?> - - + // Active theme's "CSS Personalizzato", then the site-wide custom CSS. + require __DIR__ . '/../auth/partials/theme-custom-css.php'; + ?> - - - - - - + // Custom JavaScript from settings (essential always; analytics and + // marketing only after consent). Shared with the account pages. + require __DIR__ . '/../partials/custom-js.php'; + ?> @@ -1756,11 +1578,14 @@ function loadCustomScripts() { $publicNavItems = [ ['href' => $catalogRoute, 'label' => __('Catalogo'), 'icon' => 'fa-book', 'active' => $navPathActive((string) $catalogRoute)], ]; - if ($archivesAvailable) { + if ($archivesAvailable && ConfigStore::isInPublicMenu('archives')) { $publicNavItems[] = ['href' => $archivesRoute, 'label' => __('Archivio'), 'icon' => 'fa-archive', 'active' => $navPathActive((string) $archivesRoute)]; } - if ($emerotecaAvailable) { - $publicNavItems[] = ['href' => '/emeroteca', 'label' => __('Emeroteca'), 'icon' => 'fa-newspaper', 'active' => $navPathActive('/emeroteca')]; + if ($emerotecaAvailable && ConfigStore::isInPublicMenu('emeroteca')) { + // Localized base of the 'periodicals' route key; the historical + // /emeroteca stays registered, so both spellings mark the item active. + $emerotecaRoute = \App\Support\RouteTranslator::route('periodicals'); + $publicNavItems[] = ['href' => $emerotecaRoute, 'label' => __('Emeroteca'), 'icon' => 'fa-newspaper', 'active' => $navPathActive($emerotecaRoute) || $navPathActive('/emeroteca')]; } if ($eventsEnabled) { // The localized path (/eventi in Italian); /events stays registered as @@ -1769,7 +1594,8 @@ function loadCustomScripts() { $publicNavItems[] = ['href' => $eventsRoute, 'label' => __('Eventi'), 'icon' => 'fa-calendar-alt', 'active' => $navPathActive((string) $eventsRoute) || $navPathActive('/events')]; } ?> - + +
@@ -1809,7 +1635,7 @@ class=""" method="get"> + placeholder="" aria-label="">