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..78d2171fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.8.0-rc.2] + +The second candidate includes the complete 2026 restyle from rc.1 and the subsequent review fixes and coordinated Android 1.6.0 API work. The published rc.1 tag and assets are unchanged. + +### Added +- **Archives and library Desiderata in the authenticated mobile API.** Archives expose hierarchy, signed-year filters, public descriptions, authorities, documents and exports. Desiderata has a separate list and detail, verified-account donation proposals with consent, account-scoped UUID deduplication and outcome recovery. A proposal does not create an inventory copy or alter the personal wishlist. +- **Complete book and analytic article data for Android:** every digital attachment and publisher, edition and publication place, complete genre ancestry, shared author identities, anthology metadata, five citation styles, rich HTML, RIS/MARCXML and protected staff-management links. Issue details distinguish catalogued contributions from the printed table of contents and link to the complete paginated list. +- **Release review evidence in the repository:** the Uwe requirements matrix, browser/Android comparison with disposable records, and recorded validation counts under `docs/reviews/`. + +### Changed +- **Missing book covers become readable typeset bindings**, with title, author and publisher; original artwork remains complete. Wanted-book cover metadata includes the publisher. +- **Catalogue filters and collection search agree across clients.** Publisher lookup follows author lookup; genres reach every depth; localized language names match analytic ISO codes. Archives remain alongside matching books after AJAX updates. Circulation availability applies only to inventory books. +- Mobile API is **1.5.0**, Desiderata **1.2.0**, Emeroteca **1.13.0**, and Archives **1.5.1**. Android 1.6.0 uses advertised capabilities; earlier clients retain existing circulation routes. + +### Fixed +- **Release ZIPs built from Git worktrees exclude the `.git` metadata file**, as well as ordinary Git directories. The builder rejects every forbidden root path regardless of its type; a CI regression builds and audits a real worktree package. +- **Clearing catalogue filters cancels obsolete requests** and restores the correct search state. The book detail and keywords use the available width; cover and hero regressions are covered by browser checks. +- **Article PDFs remain reachable for authorized staff** through protected routes, while public resources keep reader access. Article details do not depend on the mobile middleware class loader. Newly introduced API errors are translated in all five server locales. +- **Review findings across themes and responsive pages:** escaping and media types, keyboard and screen-reader access, readable accent/hover contrast, citation controls and published resource actions. + +### Upgrade and validation +- Desiderata's plugin upgrade adds nullable mobile user/request/hash fields and an account/request unique index to its offers table. The upgrade is additive and idempotent; existing offers and website submission flows remain valid. Use the normal application/plugin updater so the bundled plugin upgrade runs. +- The functional changes passed 50 database collection checks, 191 Emeroteca behavioral checks, 9 HTTP contracts, 36 targeted browser regressions, 17 PHP suites and PHPStan level 5. The matching Android client passed 185 unit and 19 Compose tests, debug/R8 builds and lint with zero errors. The complete PR CI must pass on the candidate's final commit before publication. +- Staff editing and uploads remain on protected PHP pages. FBI/DBC import (#52) and the QNAP YAZ environment (#57) are not declared implemented by this candidate; no production-device ANR fix is claimed. + +## [0.8.0-rc.1] + +A release candidate for the 2026 design of the public site and the reader's account pages. Every element the pages had is still there; what changes is how they look. + +### Changed +- **The public site and the account pages move to the 2026 design**: one stylesheet (`public/assets/pinakes-2026.css`) for the home, the catalogue, the book page and every other public page, with colours taken from the active theme. The home has a hero with a fan of covers next to the title, the counters, the "Πίνακες" band, latest arrivals, genre carousels and the call to action. The catalogue uses the new book cards, and its List view is a real list, one row per book. The book page has a hero tinted by the theme accent, quick facts, the digital files as cards (read the PDF inline, play the audiobook, download) and labelled share buttons. Dashboard, profile, wishlist and loans share one page head and one kind of card. The footer is a grid, with a "Follow us" column when a social profile is set. The display serif is Fraunces, self-hosted like the other fonts. +- **Form fields look the same everywhere**, the login and registration pages included: a white field with a thin rule tinted by the theme accent. A search box draws one border, with no second field inside it. +- **Two theme options replace the four layout variants** (editorial, workspace, command, soft) in Admin → Themes: the hero style (covers or centred) and the card style (classic or tinted). A theme that never saved them gets covers and classic. The homepage editor can pick up to four covers for the hero, or show the latest ones. +- **The accent stays readable as text on every theme.** On Ocean, Forest, Sunset, Teal and Coral the accent did not reach WCAG AA as text. The theme palette now carries `primary_text`, the accent darkened only as far as AA needs, used wherever the accent is text; backgrounds, borders and buttons keep the theme's colour. An axe sweep of every public and account page, on the ten bundled themes and at phone and desktop width, closed the remaining contrast, label and target-size findings. +- The fade-in animation on cards is gone. + +### Added +- **Emeroteca and Archive can leave the public menu**, like Events: a card in their admin pages takes the entry out of the desktop, mobile and account menus, while the pages stay reachable from the catalogue and search. The account menu gains the Archive entry the public menu already had. +- **The home's events section** has a title, subtitle and on/off switch in the homepage editor. +- **The header search suggestions show an article's image** ([#453](https://github.com/fabiodalez-dev/Pinakes/issues/453)): its own cover, else its issue's, else the masthead's logo, sized like a book cover. + +### Fixed +- **The account pages, login and registration** now load the theme's and the site's custom CSS, run the custom scripts and show the cookie banner, through the same partials as the public layout. +- **Analytics and marketing scripts run after consent.** The nonce-based CSP refused the script the loader injects; it now carries the loader's nonce. +- **The catalogue List view kept its rows after paging.** Moving to another page turned it into a grid of narrow cards. +- After login a reader on an Italian install landed on `/user/dashboard` instead of `/utente/bacheca`, and the header menus linked there; the empty wishlist linked to `/dashboard`, a 404. Wishlist links to books were missing the author slug. +- On phones the profile's fields overflowed their card and the closed mobile menu widened the page; archive units with a missing cover file showed a broken image, and the archive year filter overflowed its column. +- **A page that does not exist no longer writes an error.** In production every unmatched path, from bots or stale links, wrote `[ERROR]` and a full stack trace to the server's error log, which grew to hundreds of MB and buried the real errors. Only real errors are logged now. Saving a plugin's settings no longer writes trace lines on every save. +- **Saving the Open Library Google Books key reports a failed write.** It said the key was saved even when it was not. + +No migration: the new theme options live in the theme's settings JSON. + ## [0.7.94] ### Added 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/CmsController.php b/app/Controllers/CmsController.php index 2a29b1b7c..041f0b591 100644 --- a/app/Controllers/CmsController.php +++ b/app/Controllers/CmsController.php @@ -180,6 +180,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 +213,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 +261,13 @@ 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 background photo is retired with the 2026 design (the + // hero shows a fan of covers instead): the form no longer offers + // an upload, so nothing here accepts or writes one. A photo stored + // by an older version stays in `background_image`, unused. 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 +288,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 +313,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 +330,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 +462,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/FrontendController.php b/app/Controllers/FrontendController.php index a772c1076..170de80c7 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; } @@ -252,7 +264,7 @@ public function catalog(Request $request, Response $response, mysqli $db): Respo $searchTerm = trim((string) ($filters['search'] ?? '')); /** @var array> $archiveResults */ $archiveResults = $searchTerm !== '' - ? \App\Support\Hooks::apply('frontend.catalog.archive_results', [], [$searchTerm]) + ? $this->collectArchiveResults($searchTerm) : []; // Federated-search hint: the catalogue search only reads @@ -398,6 +410,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 +435,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 = $searchTerm !== '' ? $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 +585,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 +2675,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,7 +2760,8 @@ 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 { @@ -2849,7 +2945,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 +2958,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 +2967,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/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/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..83dc0905b 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,18 +67,18 @@ $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 = [ @@ -89,10 +92,14 @@ 'primary_focus' => '#b70152', 'secondary_hover' => '#0f1623', 'button_hover' => '#c20258', + 'primary_text' => '#ce015d', + 'button_surface' => '#d70262', + 'secondary_surface' => '#111827', + '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) @@ -311,11 +318,13 @@ - + + + ; + /* The accent as text: darkened only as far as AA contrast needs. */ + --primary-text: + + ; + /* 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 +460,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 +520,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 +593,7 @@ } .mobile-search-toggle:hover { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } /* Mobile search container animation */ @@ -792,7 +816,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 +1001,7 @@ } .mobile-menu-toggle:hover { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } .header-content { @@ -1113,7 +1137,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 +1397,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 +1408,7 @@ } .mobile-menu-content { - position: fixed; + position: absolute; top: 0; right: 0; width: 80%; @@ -1430,7 +1457,7 @@ } .mobile-menu-close:hover { - color: var(--primary-color); + color: var(--primary-text, var(--primary-color)); } .mobile-nav { @@ -1450,12 +1477,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 +1497,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 +1507,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,10 +1568,10 @@ 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) { + if ($emerotecaAvailable && ConfigStore::isInPublicMenu('emeroteca')) { $publicNavItems[] = ['href' => '/emeroteca', 'label' => __('Emeroteca'), 'icon' => 'fa-newspaper', 'active' => $navPathActive('/emeroteca')]; } if ($eventsEnabled) { @@ -1769,7 +1581,8 @@ function loadCustomScripts() { $publicNavItems[] = ['href' => $eventsRoute, 'label' => __('Eventi'), 'icon' => 'fa-calendar-alt', 'active' => $navPathActive((string) $eventsRoute) || $navPathActive('/events')]; } ?> - + +
@@ -1809,7 +1622,7 @@ class=""" method="get"> + placeholder="" aria-label="">