From 46414697693ee41c93519b778e052e9d361f6993 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sat, 14 Feb 2026 16:04:54 +0100 Subject: [PATCH 1/5] feat: added theme manager --- docs/development.md | 43 ++++ .../admin/assets/src/api/configuration.ts | 7 + .../assets/src/configuration/configuration.ts | 22 ++ .../admin/configuration/tab-list.twig | 7 + .../admin/configuration/themes.upload.twig | 31 +++ .../Controller/AbstractController.php | 6 +- .../Api/ConfigurationTabController.php | 53 +++++ phpmyfaq/src/phpMyFAQ/Instance/Client.php | 51 ++++- .../src/phpMyFAQ/Template/ThemeManager.php | 179 ++++++++++++++++ phpmyfaq/src/phpMyFAQ/Twig/TwigWrapper.php | 21 +- phpmyfaq/src/services.php | 6 + .../Api/ConfigurationTabControllerTest.php | 12 ++ tests/phpMyFAQ/Instance/ClientTest.php | 25 ++- tests/phpMyFAQ/Template/ThemeManagerTest.php | 200 ++++++++++++++++++ tests/phpMyFAQ/Twig/TwigWrapperTest.php | 19 ++ 15 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 phpmyfaq/assets/templates/admin/configuration/themes.upload.twig create mode 100644 phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php create mode 100644 tests/phpMyFAQ/Template/ThemeManagerTest.php diff --git a/docs/development.md b/docs/development.md index 29ac168743..678a9f955c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -496,6 +496,49 @@ Example: {{ userId | userName }} +### 9.2.3 Dynamic template set and theme upload + +phpMyFAQ v4.2 introduced dynamic template set loading and a storage-backed `ThemeManager`. + +- Runtime template set is read from configuration key `layout.templateSet`. +- `TwigWrapper` now validates the requested set and falls back to `default` if the + configured set is invalid or missing on disk. +- Tenant provisioning can skip physical template copies and only persist the + template reference in `faqconfig` (`layout.templateSet`). + +Theme uploads use `phpMyFAQ\Template\ThemeManager`: + +- Input format: ZIP archive. +- Required file: `index.twig` (theme root or `/index.twig`). +- Allowed file extensions: + `.twig`, `.css`, `.js`, `.json`, `.png`, `.jpg`, `.jpeg`, `.svg`, `.webp`, `.gif`, + `.woff`, `.woff2`, `.ttf`, `.otf`. +- Upload target: `StorageInterface` at `//...` + (default root path: `themes`). +- Activation: `layout.templateSet` is updated via `ThemeManager::activateTheme()`. + +Example usage: + +```php +use phpMyFAQ\Template\ThemeManager; + +$themeManager = new ThemeManager($configuration, $storage, 'themes'); +$themeManager->uploadTheme('tenant-theme', '/tmp/tenant-theme.zip'); +$themeManager->activateTheme('tenant-theme'); +``` + +Admin UI: + +- Open `/admin/configuration` +- Use the `layout` tab +- Upload ZIP in the `layout` tab +- Select the template in `layout.templateSet` and save configuration + +Security checks: + +- Requires `CONFIGURATION_EDIT` permission +- CSRF token (`theme-manager`) is required for upload/activation + ## 9.8 Working with the Docker containers ### 9.9.1 Create a new SSL certificate diff --git a/phpmyfaq/admin/assets/src/api/configuration.ts b/phpmyfaq/admin/assets/src/api/configuration.ts index 689e317674..1cca798924 100644 --- a/phpmyfaq/admin/assets/src/api/configuration.ts +++ b/phpmyfaq/admin/assets/src/api/configuration.ts @@ -146,3 +146,10 @@ export const saveConfiguration = async (data: FormData): Promise => { body: data, })) as Response; }; + +export const uploadThemeArchive = async (data: FormData): Promise => { + return (await fetchJson('api/configuration/themes/upload', { + method: 'POST', + body: data, + })) as Response; +}; diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.ts b/phpmyfaq/admin/assets/src/configuration/configuration.ts index 74161a8e6d..27a61975c0 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.ts @@ -28,6 +28,7 @@ import { fetchTemplates, fetchTranslations, fetchTranslationProvider, + uploadThemeArchive, saveConfiguration, } from '../api'; import { handleWebPush } from './webpush'; @@ -51,6 +52,7 @@ export const handleConfiguration = async (): Promise => { break; case '#layout': await handleTemplates(); + await handleThemes(); break; case '#records': await handleFaqsSortingKeys(); @@ -270,6 +272,26 @@ export const handleMailProvider = async (): Promise => { } }; +export const handleThemes = async (): Promise => { + const uploadForm = document.getElementById('theme-upload-form') as HTMLFormElement | null; + + if (uploadForm) { + uploadForm.addEventListener('submit', async (event: Event): Promise => { + event.preventDefault(); + + const response = (await uploadThemeArchive(new FormData(uploadForm))) as unknown as Response; + if (typeof response.success === 'string') { + pushNotification(response.success); + await handleConfigurationTab('#layout'); + await handleTemplates(); + await handleThemes(); + } else { + pushErrorNotification(response.error || 'Theme upload failed.'); + } + }); + } +}; + export const handleConfigurationTab = async (target: string): Promise => { const languageElement = document.getElementById('pmf-language') as HTMLInputElement; if (!languageElement) { diff --git a/phpmyfaq/assets/templates/admin/configuration/tab-list.twig b/phpmyfaq/assets/templates/admin/configuration/tab-list.twig index d73100c1d2..af91109b21 100644 --- a/phpmyfaq/assets/templates/admin/configuration/tab-list.twig +++ b/phpmyfaq/assets/templates/admin/configuration/tab-list.twig @@ -20,6 +20,13 @@ {% endfor %} +{% if mode == 'layout' %} + {% include '@admin/configuration/themes.upload.twig' with { + csrfToken: themeCsrfToken, + activeTheme: activeTheme + } only %} +{% endif %} +