diff --git a/.ddev/post-start.sh b/.ddev/post-start.sh index 18b9fb752..890b1da5a 100755 --- a/.ddev/post-start.sh +++ b/.ddev/post-start.sh @@ -23,8 +23,8 @@ if [ ! -d "$nodeModulesPath" ] || [ ! -f "$nodeBuildTimestamp" ] || [ "$packageL npm ci --include=dev npmBuildNeeded=1 elif [ ! -f "src/public/assets/manifest.json" ] \ - || [ ! -f "src/themes/admin_default/assets/build/manifest.json" ] \ - || [ ! -f "src/themes/huraga/assets/build/manifest.json" ]; then + || [ ! -f "src/themes/default/admin/assets/build/manifest.json" ] \ + || [ ! -f "src/themes/default/client/assets/build/manifest.json" ]; then npmBuildNeeded=1 fi diff --git a/.gitignore b/.gitignore index ebfdd2d03..e8b855d15 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ node_modules ### friendsofphp/php-cs-fixer ### .php-cs-fixer.cache src/install/php_error.log -src/themes/huraga/config/settings_data.json +src/themes/default/client/config/settings_data.json output.log ### Playwright ### diff --git a/AGENTS.md b/AGENTS.md index 96cdaa295..00ea18341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,8 @@ npm run build This command builds assets for: * Core public browser assets in `src/public/assets` -* `admin_default` theme -* `huraga` theme +* `admin_default` theme (`src/themes/default/admin`) +* `huraga` theme (`src/themes/default/client`) The core build script is `frontend/esbuild.mjs`. Theme build scripts are defined in each theme's `package.json` and use local `esbuild.mjs` files for configuration: @@ -130,10 +130,10 @@ npm run build-huraga ```bash # Theme development builds -cd src/themes/admin_default && npm run dev +cd src/themes/default/admin && npm run dev # Watch mode for active Huraga development -cd src/themes/huraga && npm run dev +cd src/themes/default/client && npm run dev ``` ### Testing @@ -189,7 +189,7 @@ src/ ├── cron.php # Scheduled tasks entry point ├── library/ # Core libraries and third-party integrations ├── modules/ # Application modules (50+ modules) -├── themes/ # UI themes (admin_default, huraga) +├── themes/ # UI themes (shipped `default` package: default/admin, default/client, default/shared) ├── public/ # Public core assets, branding, and gateway icons ├── data/ # Runtime data (cache, logs, uploads) ├── install/ # Installation scripts and assets @@ -201,7 +201,7 @@ tests/ # Pest, live API, and end-to-end test structure ### Front-end Guidelines -* `admin_default` theme icons are compiled from the `src/themes/admin_default/assets/icons` directory and can be referenced from within the Twig template like so: +* `admin_default` theme icons are compiled from the `src/themes/default/admin/custom-icons` directory and can be referenced from within the Twig template like so: ```html diff --git a/Dockerfile b/Dockerfile index a2db463b0..0ad609650 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,14 +80,14 @@ FROM node:${NODE_VERSION}-bookworm-slim AS frontend-assets WORKDIR /app COPY package.json package-lock.json ./ -COPY src/themes/admin_default/package.json src/themes/admin_default/package.json -COPY src/themes/huraga/package.json src/themes/huraga/package.json +COPY src/themes/default/admin/package.json src/themes/default/admin/package.json +COPY src/themes/default/client/package.json src/themes/default/client/package.json RUN --mount=type=cache,target=/root/.npm npm ci COPY tsconfig.json ./ -COPY src/themes/admin_default ./src/themes/admin_default -COPY src/themes/huraga ./src/themes/huraga +COPY src/themes/default/admin ./src/themes/default/admin +COPY src/themes/default/client ./src/themes/default/client COPY src/modules ./src/modules COPY frontend ./frontend @@ -110,8 +110,8 @@ COPY src ./src COPY README.md LICENSE ./src/ COPY --from=php-vendor /app/src/vendor ./src/vendor COPY --from=frontend-assets /app/src/public/assets ./src/public/assets -COPY --from=frontend-assets /app/src/themes/admin_default/assets/build ./src/themes/admin_default/assets/build -COPY --from=frontend-assets /app/src/themes/huraga/assets/build ./src/themes/huraga/assets/build +COPY --from=frontend-assets /app/src/themes/default/admin/assets/build ./src/themes/default/admin/assets/build +COPY --from=frontend-assets /app/src/themes/default/client/assets/build ./src/themes/default/client/assets/build RUN set -eux; \ find ./src/modules -type d -name tests -prune -exec rm -rf {} +; \ diff --git a/cspell.json b/cspell.json index e50791803..1ace6331b 100644 --- a/cspell.json +++ b/cspell.json @@ -55,6 +55,7 @@ "Customtest", "mysite", "myhost", + "mypackage", "inversed", "Autoresponder", "initialises", @@ -177,6 +178,8 @@ "tablist", "subpages", "subpage", + "subpath", + "subpaths", "keyup", "aligncenter", "tabpanel", @@ -234,6 +237,7 @@ "upath", "pathe", "tabler", + "tblr", "apexcharts", "topnav", "yourparamvalue", @@ -608,7 +612,7 @@ ], "ignorePaths": [ "node_modules/**", - "src/themes/*/assets/**", + "src/themes/**/assets/**", "src/install/assets/**", "src/install/sql/**", "src/modules/*/assets/**", @@ -636,8 +640,8 @@ "src/modules/Invoice/templates/pdf/fonts/OFL.txt", "src/modules/System/templates/admin/mod_system_settings.html.twig", "src/vendor", - "src/themes/*/build", - "src/themes/*/node_modules", + "src/themes/**/build", + "src/themes/**/node_modules", "phpstan.neon", "*.config.js", ".github/**", diff --git a/frontend/README.md b/frontend/README.md index 38fab640f..18153bdfb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -9,16 +9,16 @@ FOSSBilling builds shared browser assets from `frontend/` and theme assets from - `npm run check` runs lightweight JavaScript syntax checks and validates theme icon manifests. - `npm run build-admin_default` and `npm run build-huraga` build a single theme. -Generated assets are written to `src/public/assets` and `src/themes/*/assets/build`. These paths are ignored by Git and are copied into release artifacts by the Docker build. +Generated assets are written to `src/public/assets` and `src/themes/**/assets/build`. These paths are ignored by Git and are copied into release artifacts by the Docker build. ## Theme Dependencies Themes own the UI packages they consume. Shared tooling in `frontend/tools` can help compile assets, generate sprites, and post-process CSS, but it should not make a theme depend on a specific UI package. -Examples: +Examples (the shipped `default` theme package bundles both — `src/themes/default/admin` and `src/themes/default/client`; their npm workspace package names, `admin_default` and `huraga`, are unrelated to their directory paths): -- `admin_default` owns Tabler, Coloris, Chart.js, Litepicker, Tom Select, Flag Icons, and its Tabler icon package. -- `huraga` owns Bootstrap, Tom Select, Flag Icons, and its Tabler icon package. +- `admin_default` (`src/themes/default/admin`) owns Tabler, Coloris, Chart.js, Litepicker, Tom Select, Flag Icons, and its Tabler icon package. +- `huraga` (`src/themes/default/client`) owns Bootstrap, Tom Select, Flag Icons, and its Tabler icon package. - `svg-sprite` is a root development dependency because the shared sprite helper imports it. ## Icons @@ -34,6 +34,6 @@ Run `npm run check-icons` before changing icon usage. ## CSS -Theme vendor CSS belongs in `src/themes/*/assets/css/vendor.css`. Theme-specific styles belong in the theme Sass entrypoint. +Theme vendor CSS belongs in `src/themes/**/assets/css/vendor.css`. Theme-specific styles belong in the theme Sass entrypoint. Rules that are genuinely identical across the default admin and client themes belong in `frontend/styles/_shared-theme.scss` instead of being duplicated. PurgeCSS runs only for production builds. A PurgeCSS failure fails production builds so release CSS problems are caught early. diff --git a/frontend/styles/_shared-theme.scss b/frontend/styles/_shared-theme.scss new file mode 100644 index 000000000..3c9da0254 --- /dev/null +++ b/frontend/styles/_shared-theme.scss @@ -0,0 +1,25 @@ +// Rules genuinely identical between the default admin and client themes. +// Deliberately small and conservative: most component styling is +// framework-specific (Tabler vs Bootstrap custom properties) and stays +// duplicated on purpose rather than forcing a shared abstraction here. + +.fb-phone-country-code { + width: 5.5rem; + flex: 0 0 5.5rem; + max-width: 5.5rem; +} + +// Every property here was identical between the two themes except +// background-color, which is genuinely framework-specific (Tabler's +// --tblr-bg-surface vs Bootstrap's --bs-secondary-bg) - each theme's own +// stylesheet still sets that one property itself, after @use-ing this file. +.db-avatar { + display: inline-block; + flex-shrink: 0; + vertical-align: middle; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; + border-radius: 50%; + overflow: hidden; +} diff --git a/frontend/tools/check-icons.mts b/frontend/tools/check-icons.mts index 0b08e9a7f..dc6dddd17 100644 --- a/frontend/tools/check-icons.mts +++ b/frontend/tools/check-icons.mts @@ -9,23 +9,23 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const themes = [ { - code: 'huraga', + code: 'default/client', area: 'client', - themePath: resolve(rootDir, 'src/themes/huraga'), + themePath: resolve(rootDir, 'src/themes/default/client'), scanPaths: [ - resolve(rootDir, 'src/themes/huraga/html'), - resolve(rootDir, 'src/themes/huraga/assets'), + resolve(rootDir, 'src/themes/default/client/html'), + resolve(rootDir, 'src/themes/default/client/assets'), resolve(rootDir, 'src/modules'), ], disallowFilled: true, }, { - code: 'admin_default', + code: 'default/admin', area: 'admin', - themePath: resolve(rootDir, 'src/themes/admin_default'), + themePath: resolve(rootDir, 'src/themes/default/admin'), scanPaths: [ - resolve(rootDir, 'src/themes/admin_default/html'), - resolve(rootDir, 'src/themes/admin_default/assets'), + resolve(rootDir, 'src/themes/default/admin/html'), + resolve(rootDir, 'src/themes/default/admin/assets'), resolve(rootDir, 'src/modules'), ], dynamicNavigation: true, diff --git a/package-lock.json b/package-lock.json index d1fbe0e86..05ea2e6c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,8 @@ "name": "fossbilling", "license": "Apache License 2.0", "workspaces": [ - "src/themes/huraga", - "src/themes/admin_default" + "src/themes/default/client", + "src/themes/default/admin" ], "devDependencies": { "@playwright/test": "1.62.1", @@ -2515,7 +2515,7 @@ } }, "node_modules/admin_default": { - "resolved": "src/themes/admin_default", + "resolved": "src/themes/default/admin", "link": true }, "node_modules/ansi-regex": { @@ -3797,7 +3797,7 @@ } }, "node_modules/huraga": { - "resolved": "src/themes/huraga", + "resolved": "src/themes/default/client", "link": true }, "node_modules/immutable": { @@ -6072,7 +6072,8 @@ "url": "https://github.com/sponsors/wooorm" } }, - "src/themes/admin_default": { + "src/themes/default/admin": { + "name": "admin_default", "dependencies": { "@tabler/core": "^1.2.0", "chart.js": "^4.5.1", @@ -6088,7 +6089,8 @@ "node": ">=24" } }, - "src/themes/huraga": { + "src/themes/default/client": { + "name": "huraga", "dependencies": { "bootstrap": "^5.3.3", "flag-icons": "^7.2.3", diff --git a/package.json b/package.json index 226851d92..b72179e00 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "url": "https://github.com/FOSSBilling/FOSSBilling/issues" }, "workspaces": [ - "src/themes/huraga", - "src/themes/admin_default" + "src/themes/default/client", + "src/themes/default/admin" ], "devDependencies": { "@playwright/test": "1.62.1", diff --git a/src/install/install.php b/src/install/install.php index d38723492..c9262995a 100644 --- a/src/install/install.php +++ b/src/install/install.php @@ -54,8 +54,8 @@ define('PATH_MODS', Path::join(PATH_ROOT, 'modules')); define('PATH_DATA', Path::join(PATH_ROOT, 'data')); define('PATH_CACHE', Path::join(PATH_DATA, 'cache')); define('PATH_LOG', Path::join(PATH_DATA, 'log')); -define('HURAGA_CONFIG', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json')); -define('HURAGA_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json.example')); +define('CLIENT_THEME_CONFIG', Path::join(PATH_THEMES, 'default', 'client', 'config', 'settings_data.json')); +define('CLIENT_THEME_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'default', 'client', 'config', 'settings_data.json.example')); define('PATH_HTACCESS', Path::join(PATH_ROOT, '.htaccess')); define('PAGE_INSTALL', Path::join('./assets', 'install.html.twig')); define('PAGE_RESULT', Path::join('./assets', 'result.html.twig')); @@ -543,8 +543,8 @@ final class FOSSBilling_Installer $this->installPortable(); // Copy config templates when applicable - if (!$this->filesystem->exists(HURAGA_CONFIG) && $this->filesystem->exists(HURAGA_CONFIG_TEMPLATE)) { - $this->filesystem->copy(HURAGA_CONFIG_TEMPLATE, HURAGA_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually. + if (!$this->filesystem->exists(CLIENT_THEME_CONFIG) && $this->filesystem->exists(CLIENT_THEME_CONFIG_TEMPLATE)) { + $this->filesystem->copy(CLIENT_THEME_CONFIG_TEMPLATE, CLIENT_THEME_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually. } // If .htaccess doesn't exist, fetch the latest from GitHub. diff --git a/src/install/sql/content.sql b/src/install/sql/content.sql index 4f75a6640..84c672c15 100644 --- a/src/install/sql/content.sql +++ b/src/install/sql/content.sql @@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE; INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`) VALUES - (1,'last_patch','114',0,NULL,NULL,NOW(),NOW()), + (1,'last_patch','115',0,NULL,NULL,NOW(),NOW()), (2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()), (3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()), (4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()), @@ -321,7 +321,7 @@ VALUES (15,'invoice_due_days','5',0,NULL,NULL,NOW(),NOW()), (16,'invoice_auto_approval','1',0,NULL,NULL,NOW(),NOW()), (17,'invoice_issue_days_before_expire','14',0,NULL,NULL,NOW(),NOW()), - (18,'theme','huraga',0,NULL,NULL,NOW(),NOW()), + (18,'theme','default/client',0,NULL,NULL,NOW(),NOW()), (19,'issue_invoice_days_before_expire','7',0,NULL,NULL,NOW(),NOW()), (20,'invoice_refund_logic','credit_note',0,NULL,NULL,NOW(),NOW()), (21,'invoice_cn_series','CN-',0,NULL,NULL,NOW(),NOW()), diff --git a/src/library/FOSSBilling/SentryHelper.php b/src/library/FOSSBilling/SentryHelper.php index ca6132232..1a91e7049 100644 --- a/src/library/FOSSBilling/SentryHelper.php +++ b/src/library/FOSSBilling/SentryHelper.php @@ -79,10 +79,11 @@ class SentryHelper 'theme', ]; - // Themes we want to receive error reports for + // Themes we want to receive error reports for. extractName() (below) resolves + // to the top-level directory under PATH_THEMES, so 'default' alone covers + // default/admin, default/client, and default/shared. private const array ALLOWED_THEMES = [ - 'admin_default', - 'huraga', + 'default', ]; // Array containing instance IDs that are blacklisted from error reporting and a timestamp of when their blacklist expires. diff --git a/src/library/FOSSBilling/Twig/TwigFactory.php b/src/library/FOSSBilling/Twig/TwigFactory.php index 7195a2ea1..aa089c645 100644 --- a/src/library/FOSSBilling/Twig/TwigFactory.php +++ b/src/library/FOSSBilling/Twig/TwigFactory.php @@ -145,8 +145,9 @@ class TwigFactory $service = $this->di['mod_service']('theme'); $theme = $service->getCurrentAdminAreaTheme(); + $sharedPath = $service->getPackageSharedHtmlPath($theme['code']); - $loader = new TwigLoader(AppArea::ADMIN, Path::join(PATH_THEMES, $theme['code'])); + $loader = new TwigLoader(AppArea::ADMIN, Path::join(PATH_THEMES, $theme['code']), $sharedPath); $twig->setLoader($loader); $twig->addGlobal('theme', $theme); @@ -176,8 +177,9 @@ class TwigFactory $code = $service->getCurrentClientAreaThemeCode(); $theme = $service->getTheme($code); $settings = $service->getThemeSettings($theme); + $sharedPath = $service->getPackageSharedHtmlPath($code); - $loader = new TwigLoader(AppArea::CLIENT, Path::join(PATH_THEMES, $code)); + $loader = new TwigLoader(AppArea::CLIENT, Path::join(PATH_THEMES, $code), $sharedPath); $twig->setLoader($loader); $twig->addGlobal('current_theme', $code); diff --git a/src/library/FOSSBilling/Twig/TwigLoader.php b/src/library/FOSSBilling/Twig/TwigLoader.php index 57519f9b5..547b8449c 100644 --- a/src/library/FOSSBilling/Twig/TwigLoader.php +++ b/src/library/FOSSBilling/Twig/TwigLoader.php @@ -24,16 +24,21 @@ class TwigLoader extends FilesystemLoader /** * Create new TwigLoader with FOSSBilling-specific path configuration. * - * @param AppArea $appArea either 'admin' or 'client' - * @param string $themePath path to the theme directory + * @param AppArea $appArea either 'admin' or 'client' + * @param string $themePath path to the theme (or theme package area) directory + * @param string|null $sharedHtmlPath path to a theme package's `shared/html` directory, or + * null when `$themePath` isn't part of a package. Resolved + * via Theme\Service::getPackageSharedHtmlPath(). Lowest + * priority tier; silently skipped if it doesn't exist. */ - public function __construct(AppArea $appArea, string $themePath) + public function __construct(AppArea $appArea, string $themePath, ?string $sharedHtmlPath = null) { parent::__construct(); $this->filesystem = new Filesystem(); - // Load path in priority order: custom theme files, default theme, module templates. + // Load path in priority order: custom theme files, default theme, + // module templates, package-shared theme resources. $paths = []; $customPath = Path::join($themePath, 'html_custom'); if ($this->filesystem->exists($customPath)) { @@ -45,6 +50,10 @@ class TwigLoader extends FilesystemLoader } $paths = array_merge($paths, $this->getModuleTemplatePaths($appArea)); + if ($sharedHtmlPath !== null && $this->filesystem->exists($sharedHtmlPath)) { + $paths[] = $sharedHtmlPath; + } + $this->setPaths($paths); // Add additional path to load symbols if they exist. diff --git a/src/library/FOSSBilling/UpdatePatcher.php b/src/library/FOSSBilling/UpdatePatcher.php index fd8e8a5e0..1391c9b08 100644 --- a/src/library/FOSSBilling/UpdatePatcher.php +++ b/src/library/FOSSBilling/UpdatePatcher.php @@ -190,6 +190,13 @@ class UpdatePatcher implements InjectionAwareInterface } } + // Portable (plain UPDATE ... WHERE, no MySQL-specific syntax) and idempotent, so it + // runs on every platform rather than being folded into the MySQL-only patch loop above - + // a PostgreSQL/SQLite install predating this change never runs patch115() at all, and + // would otherwise be left with a theme that never got renamed and orphaned saved settings + // forever. + $this->migrateThemePackageLayout(); + // Additive structural sync runs on every platform, MySQL/MariaDB included: it picks up any // column/table/index that's on entity metadata but not yet applied, without needing a // hand-written patch for it - the only mechanism at all on PostgreSQL/SQLite, and on @@ -678,6 +685,7 @@ class UpdatePatcher implements InjectionAwareInterface 112 => 'patch112', 113 => 'patch113', 114 => 'patch114', + 115 => 'patch115', ]; ksort($patches, SORT_NATURAL); @@ -2834,6 +2842,81 @@ class UpdatePatcher implements InjectionAwareInterface } } + private function patch115(): void + { + $this->migrateThemePackageLayout(); + } + + /** + * Bundles the shipped themes into one package: admin_default -> default/admin, + * huraga -> default/client. Third-party themes are untouched. + * + * Unlike the raw-DDL patches above, this is plain, portable SQL (no backticks, + * ENGINE=, or ON DUPLICATE KEY UPDATE) and a filesystem rename - neither is + * MySQL-specific, so this is called both from patch115() (for MySQL/MariaDB's + * sequential patch-level bookkeeping) and unconditionally from + * applyCorePatches() below, so PostgreSQL/SQLite installs - which never run the + * patchNNN() loop at all - still get migrated. Every step is idempotent, so + * running it twice on a MySQL/MariaDB install (once via patch115(), once via + * the unconditional call) is a harmless no-op the second time. + */ + private function migrateThemePackageLayout(): void + { + $filesystem = $this->filesystem; + + // Each shipped theme's old code, new code, and the setting param that selects it. + $renames = [ + 'admin_default' => ['newCode' => 'default/admin', 'settingParam' => 'admin_theme'], + 'huraga' => ['newCode' => 'default/client', 'settingParam' => 'theme'], + ]; + + foreach ($renames as $oldCode => $rename) { + $oldPath = Path::join(PATH_THEMES, $oldCode); + $newPath = Path::join(PATH_THEMES, $rename['newCode']); + + if ($filesystem->exists($oldPath) && !$filesystem->exists($newPath)) { + $filesystem->mkdir(Path::getDirectory($newPath)); + $filesystem->rename($oldPath, $newPath); + } + + // A code-only deploy (e.g. `git pull`) already moves every tracked file via the + // checkout itself, before this ever runs - the rename above then finds $newPath + // already there and skips. What's left behind at $oldPath at that point is mostly + // gitignored leftovers (a rebuilt assets/build/, huraga's config/settings_data.json + // cache, which regenerates on its own - the setting it holds is now in the database, + // migrated below), but TwigLoader's `html_custom` override directory and extra files + // dropped into `custom-icons` are genuinely untracked local customizations a checkout + // never touches - discarding $oldPath outright would destroy them. Mirror anything not + // already present at $newPath over first (never overwriting what the checkout already + // placed there) so those customizations survive the rename, then discard what's left. + if ($filesystem->exists($oldPath) && $filesystem->exists($newPath)) { + $filesystem->mirror($oldPath, $newPath, null, ['override' => false]); + $filesystem->remove($oldPath); + } + + // Safe/no-op if the row doesn't currently hold the old value. + $this->executeSql('UPDATE setting SET value = :new_value WHERE param = :param AND value = :old_value', [ + 'new_value' => $rename['newCode'], + 'param' => $rename['settingParam'], + 'old_value' => $oldCode, + ]); + + // Saved theme settings/presets live in extension_meta, keyed by the theme's + // name string (Theme\Service::updateSettings()/setCurrentThemePreset()) - + // 'settings' rows in rel_id, the 'preset'/'current' row in meta_key. Without + // this, a staff member's customized theme settings would silently fall back + // to the shipped defaults once the theme is renamed. + $this->executeSql("UPDATE extension_meta SET rel_id = :new_code WHERE extension = 'mod_theme' AND rel_type = 'settings' AND rel_id = :old_code", [ + 'new_code' => $rename['newCode'], + 'old_code' => $oldCode, + ]); + $this->executeSql("UPDATE extension_meta SET meta_key = :new_code WHERE extension = 'mod_theme' AND rel_type = 'preset' AND rel_id = 'current' AND meta_key = :old_code", [ + 'new_code' => $rename['newCode'], + 'old_code' => $oldCode, + ]); + } + } + private function patch114(): void { // Enforce unique session_id on cart at the DB level (matches the Cart entity diff --git a/src/modules/Orderbutton/manifest.json b/src/modules/Orderbutton/manifest.json index fa60eca71..d0a9048d6 100644 --- a/src/modules/Orderbutton/manifest.json +++ b/src/modules/Orderbutton/manifest.json @@ -2,7 +2,7 @@ "id": "orderbutton", "type": "mod", "name": "Order Button", - "description": "Provides ability to easily attach order form to any template. It is used by huraga template by default. ", + "description": "Provides ability to easily attach order form to any template. It is used by the default client theme. ", "icon_url": "icon.svg", "version": "1.0.0" } diff --git a/src/modules/Page/tests/Unit/ServiceTest.php b/src/modules/Page/tests/Unit/ServiceTest.php index 19ae61bf7..e71852252 100644 --- a/src/modules/Page/tests/Unit/ServiceTest.php +++ b/src/modules/Page/tests/Unit/ServiceTest.php @@ -21,7 +21,7 @@ test('getPairs returns array', function (): void { /** @var Mockery\Expectation $expectation */ $expectation = $themeService->shouldReceive('getCurrentClientAreaThemeCode'); $expectation->atLeast()->once(); - $expectation->andReturn('huraga'); + $expectation->andReturn('default/client'); $di = container(); $di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $themeService); diff --git a/src/modules/Theme/Api/Admin.php b/src/modules/Theme/Api/Admin.php index 053312e70..ea4a649f5 100644 --- a/src/modules/Theme/Api/Admin.php +++ b/src/modules/Theme/Api/Admin.php @@ -86,21 +86,43 @@ class Admin extends \FOSSBilling\Api\AbstractApi } /** - * Set new theme as default. + * Set new theme as default for an area. The caller must state the area + * via `client` rather than having it inferred from the theme code, since + * a package-shaped code (e.g. `default/admin`) can't be reliably + * classified by name alone. */ #[RequiredParams(['code' => 'Theme code was not passed'])] public function select($data): bool { $this->checkPermissions('theme', 'manage'); - $theme = $this->getService()->getTheme($data['code']); + if (!array_key_exists('client', $data)) { + throw new \FOSSBilling\InformationException('The "client" parameter is required.'); + } + if ($this->isInvalidClientParameter($data['client'])) { + throw new \FOSSBilling\InformationException('Invalid "client" parameter.'); + } + $client = Tools::normalizeBoolean($data['client'], true); + + // Confirm the code is actually available for the target area before + // persisting it. Merely existing on disk isn't enough - a package's + // `shared` tier or the opposite area's code would otherwise pass, + // leaving the area's Twig environment pointed at a directory with no + // layouts of its own. + $isAvailableForArea = false; + foreach ($this->getService()->getThemes($client) as $theme) { + if ($theme['code'] === $data['code']) { + $isAvailableForArea = true; + + break; + } + } + if (!$isAvailableForArea) { + throw new \FOSSBilling\InformationException('Theme ":code" is not available for the selected area.', [':code' => $data['code']]); + } $systemService = $this->getDi()['mod_service']('system'); - if ($theme->isAdminAreaTheme()) { - $systemService->setParamValue('admin_theme', $data['code']); - } else { - $systemService->setParamValue('theme', $data['code']); - } + $systemService->setParamValue($client ? 'theme' : 'admin_theme', $data['code']); // Clear theme cache so subsequent calls get the updated theme \Box\Mod\Theme\Service::clearThemeCache(); diff --git a/src/modules/Theme/Controller/Admin.php b/src/modules/Theme/Controller/Admin.php index 79b7ae125..0b6cfa874 100644 --- a/src/modules/Theme/Controller/Admin.php +++ b/src/modules/Theme/Controller/Admin.php @@ -29,8 +29,9 @@ class Admin implements \FOSSBilling\InjectionAwareInterface public function register(\Box_App &$app): void { - $app->get('/theme/:theme', 'get_theme', ['theme' => '[a-z0-9-_]+'], static::class); - $app->post('/theme/:theme', 'save_theme_settings', ['theme' => '[a-z0-9-_]+'], static::class); + // Allows '/' so package-shaped theme codes (e.g. 'default/admin') match. + $app->get('/theme/:theme', 'get_theme', ['theme' => '[a-zA-Z0-9_\-/]+'], static::class); + $app->post('/theme/:theme', 'save_theme_settings', ['theme' => '[a-zA-Z0-9_\-/]+'], static::class); } /** diff --git a/src/modules/Theme/Model/Theme.php b/src/modules/Theme/Model/Theme.php index 68ff92f84..e32c90b5c 100644 --- a/src/modules/Theme/Model/Theme.php +++ b/src/modules/Theme/Model/Theme.php @@ -35,11 +35,6 @@ class Theme return $this->name; } - public function isAdminAreaTheme(): bool - { - return str_contains((string) $this->name, 'admin_'); - } - public function isAssetsPathWritable(): bool { return is_writable($this->getPathAssets()); diff --git a/src/modules/Theme/Service.php b/src/modules/Theme/Service.php index 5e527f75a..612249f27 100644 --- a/src/modules/Theme/Service.php +++ b/src/modules/Theme/Service.php @@ -279,7 +279,7 @@ class Service implements InjectionAwareInterface public function getCurrentAdminAreaTheme(): array { - $default = 'admin_default'; + $default = 'default/admin'; if (self::$adminThemeCache !== null) { // Apply default logic when returning from cache @@ -320,7 +320,7 @@ class Service implements InjectionAwareInterface { if (self::$clientThemeCache !== null) { // Apply default logic when returning from cache - return !empty(self::$clientThemeCache) ? self::$clientThemeCache : 'huraga'; + return !empty(self::$clientThemeCache) ? self::$clientThemeCache : 'default/client'; } $theme = $this->di['dbal']->fetchOne("SELECT value FROM setting WHERE param = 'theme' "); @@ -328,10 +328,15 @@ class Service implements InjectionAwareInterface // Cache the raw database value (use empty string instead of null to mark as cached) self::$clientThemeCache = $theme ?? ''; - return !empty($theme) ? $theme : 'huraga'; + return !empty($theme) ? $theme : 'default/client'; } /** + * A theme directory is either flat (`{name}/html`, classified by the + * legacy `str_contains($name, 'admin')` naming) or a package + * (`{name}/admin/html` and/or `{name}/client/html`, each area listed + * under its own bucket via the resolved code `{name}/{area}`). + * * @return mixed[] */ public function getThemes($client = true): array @@ -341,14 +346,22 @@ class Service implements InjectionAwareInterface $finder = new Finder(); $finder->directories()->in($path)->depth('== 0')->ignoreDotFiles(true); - foreach ($finder as $file) { + foreach ($finder as $dir) { + $name = $dir->getFilename(); + try { - if (!$client && str_contains($file->getFilename(), 'admin')) { - $list[] = $this->buildThemeConfig($file->getFilename()); + if ($this->filesystem->exists(Path::join($dir->getPathname(), 'html'))) { + // Flat theme: unchanged legacy behavior. + if ($client === !str_contains($name, 'admin')) { + $list[] = $this->buildThemeConfig($name); + } + + continue; } - if ($client && !str_contains($file->getFilename(), 'admin')) { - $list[] = $this->buildThemeConfig($file->getFilename()); + $area = $client ? 'client' : 'admin'; + if ($this->filesystem->exists(Path::join($dir->getPathname(), $area, 'html'))) { + $list[] = $this->buildThemeConfig("$name/$area"); } } catch (\Exception $e) { $this->di['logger']->error($e->getMessage()); @@ -358,13 +371,29 @@ class Service implements InjectionAwareInterface return $list; } + /** + * Resolve a theme package's `shared/html` fallback directory from one + * area's already-resolved code (e.g. `'default/admin'`). A code with no + * `/` is a flat theme and has no package to share with. + */ + public function getPackageSharedHtmlPath(string $code): ?string + { + if (!str_contains($code, '/')) { + return null; + } + + $packagePath = Path::getDirectory(Path::join($this->getThemesPath(), $code)); + + return Path::join($packagePath, 'shared', 'html'); + } + public function getThemeConfig($client = true, $mod = null) { if ($client) { - $default = 'huraga'; + $default = 'default/client'; $theme = $this->getCurrentClientAreaThemeCode(); } else { - $default = 'admin_default'; + $default = 'default/admin'; $systemService = $this->di['mod_service']('system'); $theme = $systemService->getParamValue('admin_theme', $default); } diff --git a/src/modules/Theme/templates/admin/mod_theme_settings.html.twig b/src/modules/Theme/templates/admin/mod_theme_settings.html.twig index 0baceec8e..8cba32306 100644 --- a/src/modules/Theme/templates/admin/mod_theme_settings.html.twig +++ b/src/modules/Theme/templates/admin/mod_theme_settings.html.twig @@ -52,7 +52,7 @@
- {{ theme.code|capitalize }} + {{ theme.name|default(theme.code|capitalize) }} {% if current_client_theme.code == theme.code %} {{ 'Current'|trans }} {% endif %} @@ -73,7 +73,7 @@ {% if current_client_theme.code != theme.code %} - + @@ -120,7 +120,7 @@
- {{ theme.code|replace({ 'admin_': '' })|capitalize }} + {{ theme.name|default(theme.code|capitalize) }} {% if current_admin_theme.code == theme.code %} {{ 'Current'|trans }} {% endif %} @@ -141,7 +141,7 @@ {% if current_admin_theme.code != theme.code %} - + diff --git a/src/modules/Theme/tests/Unit/Api/AdminTest.php b/src/modules/Theme/tests/Unit/Api/AdminTest.php index 5db57581f..7e72da2ba 100644 --- a/src/modules/Theme/tests/Unit/Api/AdminTest.php +++ b/src/modules/Theme/tests/Unit/Api/AdminTest.php @@ -56,27 +56,24 @@ test('testGet', function (): void { expect($result)->toBeArray(); }); -test('testSelectNotAdminTheme', function (): void { +test('testSelectClientTheme', function (): void { $api = apiEndpoint(new Admin()); $data = [ - 'code' => 'pjw', + 'code' => 'default/client', + 'client' => true, ]; - $themeMock = Mockery::mock(Box\Mod\Theme\Model\Theme::class); - $themeMock->shouldReceive('isAdminAreaTheme') - ->atLeast() - ->once() - ->andReturn(false); - $serviceMock = Mockery::mock(Box\Mod\Theme\Service::class); - $serviceMock->shouldReceive('getTheme') + $serviceMock->shouldReceive('getThemes') ->atLeast() ->once() - ->andReturn($themeMock); + ->with(true) + ->andReturn([['code' => 'default/client']]); $systemServiceMock = Mockery::mock(Box\Mod\System\Service::class); $systemServiceMock->shouldReceive('setParamValue') - ->with('theme', Mockery::any()); + ->once() + ->with('theme', 'default/client'); $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing(); $staffServiceMock->shouldReceive('checkPermissionsAndThrowException') ->atLeast() @@ -95,24 +92,21 @@ test('testSelectNotAdminTheme', function (): void { test('testSelectAdminTheme', function (): void { $api = apiEndpoint(new Admin()); $data = [ - 'code' => 'pjw', + 'code' => 'default/admin', + 'client' => false, ]; - $themeMock = Mockery::mock(Box\Mod\Theme\Model\Theme::class); - $themeMock->shouldReceive('isAdminAreaTheme') - ->atLeast() - ->once() - ->andReturn(true); - $serviceMock = Mockery::mock(Box\Mod\Theme\Service::class); - $serviceMock->shouldReceive('getTheme') + $serviceMock->shouldReceive('getThemes') ->atLeast() ->once() - ->andReturn($themeMock); + ->with(false) + ->andReturn([['code' => 'default/admin']]); $systemServiceMock = Mockery::mock(Box\Mod\System\Service::class); $systemServiceMock->shouldReceive('setParamValue') - ->with('admin_theme', Mockery::any()); + ->once() + ->with('admin_theme', 'default/admin'); $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing(); $staffServiceMock->shouldReceive('checkPermissionsAndThrowException') ->atLeast() @@ -128,6 +122,84 @@ test('testSelectAdminTheme', function (): void { expect($result)->toBeTrue(); }); +test('testSelectRequiresClientParameter', function (): void { + $api = apiEndpoint(new Admin()); + $data = ['code' => 'default/admin']; + + $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing(); + $staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once(); + + $di = container(); + $di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock); + $api->setDi($di); + $api->setService(Mockery::mock(Box\Mod\Theme\Service::class)); + + expect(fn (): bool => $api->select($data)) + ->toThrow(FOSSBilling\InformationException::class, 'The "client" parameter is required.'); +}); + +test('testSelectRejectsInvalidClientParameter', function (): void { + $api = apiEndpoint(new Admin()); + $data = ['code' => 'default/admin', 'client' => 'not-a-boolean']; + + $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing(); + $staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once(); + + $di = container(); + $di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock); + $api->setDi($di); + $api->setService(Mockery::mock(Box\Mod\Theme\Service::class)); + + expect(fn (): bool => $api->select($data)) + ->toThrow(FOSSBilling\InformationException::class, 'Invalid "client" parameter.'); +}); + +test('testSelectRejectsThemeFromOppositeArea', function (): void { + $api = apiEndpoint(new Admin()); + $data = ['code' => 'default/admin', 'client' => true]; + + $serviceMock = Mockery::mock(Box\Mod\Theme\Service::class); + $serviceMock->shouldReceive('getThemes') + ->atLeast() + ->once() + ->with(true) + ->andReturn([['code' => 'default/client']]); + + $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing(); + $staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once(); + + $di = container(); + $di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock); + $api->setDi($di); + $api->setService($serviceMock); + + expect(fn (): bool => $api->select($data)) + ->toThrow(FOSSBilling\InformationException::class, 'Theme "default/admin" is not available for the selected area.'); +}); + +test('testSelectRejectsPackageSharedTierCode', function (): void { + $api = apiEndpoint(new Admin()); + $data = ['code' => 'default/shared', 'client' => true]; + + $serviceMock = Mockery::mock(Box\Mod\Theme\Service::class); + $serviceMock->shouldReceive('getThemes') + ->atLeast() + ->once() + ->with(true) + ->andReturn([['code' => 'default/client']]); + + $staffServiceMock = Mockery::mock(Box\Mod\Staff\Service::class)->shouldIgnoreMissing(); + $staffServiceMock->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once(); + + $di = container(); + $di['mod_service'] = $di->protect(fn (string $name = ''): Mockery\MockInterface => $staffServiceMock); + $api->setDi($di); + $api->setService($serviceMock); + + expect(fn (): bool => $api->select($data)) + ->toThrow(FOSSBilling\InformationException::class, 'Theme "default/shared" is not available for the selected area.'); +}); + test('testPresetDelete', function (): void { $api = apiEndpoint(new Admin()); $data = [ diff --git a/src/modules/Theme/tests/Unit/Controller/AdminTest.php b/src/modules/Theme/tests/Unit/Controller/AdminTest.php index affcdfc85..303dd23c5 100644 --- a/src/modules/Theme/tests/Unit/Controller/AdminTest.php +++ b/src/modules/Theme/tests/Unit/Controller/AdminTest.php @@ -24,12 +24,12 @@ use function Tests\Helpers\container; * * @return list */ -function renderHuragaFooterLinkCheckboxes(array $settings): array +function renderClientThemeFooterLinkCheckboxes(array $settings): array { $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); $twig->addFilter(new TwigFilter('trans', static fn (mixed $value): string => (string) $value)); - $theme = new Theme('huraga'); + $theme = new Theme('default/client'); $html = SandboxedStringRenderer::render( $twig, $theme->getSettingsPageHtml(), @@ -140,8 +140,8 @@ test('getTheme renders theme preset', function (): void { $di['mod'] = $di->protect(fn () => $modMock); $controller->setDi($di); - $boxAppMock->shouldReceive('getRequest')->andReturn(Symfony\Component\HttpFoundation\Request::create('/theme/huraga')); - $controller->get_theme($boxAppMock, 'huraga'); + $boxAppMock->shouldReceive('getRequest')->andReturn(Symfony\Component\HttpFoundation\Request::create('/theme/default/client')); + $controller->get_theme($boxAppMock, 'default/client'); }); test('save theme settings reads body from request and strips preset control keys', function (): void { @@ -149,7 +149,7 @@ test('save theme settings reads body from request and strips preset control keys $di = container(); $themeMock = Mockery::mock(Theme::class); - $themeMock->shouldReceive('getName')->andReturn('huraga'); + $themeMock->shouldReceive('getName')->andReturn('default/client'); $themeMock->shouldReceive('isAssetsPathWritable')->andReturn(true); $themeServiceMock = Mockery::mock(Box\Mod\Theme\Service::class); @@ -182,7 +182,7 @@ test('save theme settings reads body from request and strips preset control keys $di['events_manager'] = $eventsManager; $controller->setDi($di); - $request = Symfony\Component\HttpFoundation\Request::create('/theme/huraga', 'POST', [ + $request = Symfony\Component\HttpFoundation\Request::create('/theme/default/client', 'POST', [ 'color' => 'blue', 'save-current-setting' => '1', 'save-current-setting-preset' => 'My Preset', @@ -192,15 +192,15 @@ test('save theme settings reads body from request and strips preset control keys $boxAppMock->shouldReceive('getRequest')->once()->andReturn($request); $boxAppMock->shouldReceive('redirect') ->once() - ->with('/theme/huraga') - ->andReturn(new Symfony\Component\HttpFoundation\RedirectResponse('/theme/huraga')); + ->with('/theme/default/client') + ->andReturn(new Symfony\Component\HttpFoundation\RedirectResponse('/theme/default/client')); - $response = $controller->save_theme_settings($boxAppMock, 'huraga'); + $response = $controller->save_theme_settings($boxAppMock, 'default/client'); expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\RedirectResponse::class); }); -test('huraga footer link checkboxes submit canonical enabled values', function (): void { - $checkboxes = renderHuragaFooterLinkCheckboxes([]); +test('default/client footer link checkboxes submit canonical enabled values', function (): void { + $checkboxes = renderClientThemeFooterLinkCheckboxes([]); expect($checkboxes)->toHaveCount(5); foreach ($checkboxes as $index => $checkbox) { @@ -216,7 +216,7 @@ test('theme settings restore checkbox values saved with the browser default', fu $settings['footer_link_' . $index . '_enabled'] = 'on'; } - $checkboxes = renderHuragaFooterLinkCheckboxes($settings); + $checkboxes = renderClientThemeFooterLinkCheckboxes($settings); expect($checkboxes)->toHaveCount(5); foreach ($checkboxes as $checkbox) { diff --git a/src/modules/Theme/tests/Unit/Model/ThemeTest.php b/src/modules/Theme/tests/Unit/Model/ThemeTest.php index e548f523c..eb068f11f 100644 --- a/src/modules/Theme/tests/Unit/Model/ThemeTest.php +++ b/src/modules/Theme/tests/Unit/Model/ThemeTest.php @@ -10,7 +10,7 @@ declare(strict_types=1); -$existingTheme = 'huraga'; +$existingTheme = 'default/client'; test('get name', function () use ($existingTheme): void { $themeModel = new Box\Mod\Theme\Model\Theme($existingTheme); @@ -24,12 +24,6 @@ test('not existing theme', function (): void { })->toThrow(FOSSBilling\Exception::class, "Theme '{$themeName}' does not exist."); }); -test('is admin area theme', function () use ($existingTheme): void { - $theme = new Box\Mod\Theme\Model\Theme($existingTheme); - $result = $theme->isAdminAreaTheme(); - expect($result)->toBeBool(); -}); - test('is assets path writable', function () use ($existingTheme): void { $theme = new Box\Mod\Theme\Model\Theme($existingTheme); $result = $theme->isAssetsPathWritable(); @@ -127,3 +121,13 @@ test('get path settings data file', function () use ($existingTheme): void { expect($result)->toBeString(); expect(str_contains($result, 'settings_data.json'))->toBeTrue(); }); + +test('a package-shaped name (containing a slash) resolves paths correctly', function (): void { + $theme = new Box\Mod\Theme\Model\Theme('default/admin'); + + expect($theme->getName())->toBe('default/admin') + ->and($theme->getPath())->toBe(Symfony\Component\Filesystem\Path::join(PATH_THEMES, 'default', 'admin')) + ->and($theme->getPathHtml())->toBe(Symfony\Component\Filesystem\Path::join(PATH_THEMES, 'default', 'admin', 'html')) + ->and($theme->getPathConfig())->toBe(Symfony\Component\Filesystem\Path::join(PATH_THEMES, 'default', 'admin', 'config')) + ->and($theme->getUrl())->toContain('default/admin'); +}); diff --git a/src/modules/Theme/tests/Unit/ServiceTest.php b/src/modules/Theme/tests/Unit/ServiceTest.php index 2268da838..7d0d586ea 100644 --- a/src/modules/Theme/tests/Unit/ServiceTest.php +++ b/src/modules/Theme/tests/Unit/ServiceTest.php @@ -13,9 +13,11 @@ declare(strict_types=1); use Box\Mod\Extension\Entity\ExtensionMeta; use Box\Mod\Theme\Model; use Box\Mod\Theme\Service; +use Symfony\Component\Filesystem\Path; use function Tests\Helpers\container; use function Tests\Helpers\injectMockFilesystem; +use function Tests\Helpers\moduleService; function themeContainerWithRepository(Mockery\MockInterface $repository, ?Mockery\MockInterface $em = null): Pimple\Container { @@ -45,7 +47,7 @@ test('getDi returns the dependency injection container', function (): void { test('getTheme returns a Theme model instance', function (): void { $service = new Service(); - $result = $service->getTheme('huraga'); + $result = $service->getTheme('default/client'); expect($result)->toBeInstanceOf(Model\Theme::class); }); @@ -426,7 +428,7 @@ test('getCurrentClientAreaThemeCode returns theme code', function (): void { $dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class); $dbalMock->shouldReceive('fetchOne')->once() ->with("SELECT value FROM setting WHERE param = 'theme' ") - ->andReturn('huraga'); + ->andReturn('default/client'); $di = container(); $di['dbal'] = $dbalMock; @@ -434,7 +436,7 @@ test('getCurrentClientAreaThemeCode returns theme code', function (): void { $result = $service->getCurrentClientAreaThemeCode(); expect($result)->toBeString(); - expect($result)->toBe('huraga'); + expect($result)->toBe('default/client'); }); test('getCurrentClientAreaThemeCode falls back when the setting is missing', function (): void { @@ -449,5 +451,108 @@ test('getCurrentClientAreaThemeCode falls back when the setting is missing', fun $di['dbal'] = $dbalMock; $service->setDi($di); - expect($service->getCurrentClientAreaThemeCode())->toBe('huraga'); + expect($service->getCurrentClientAreaThemeCode())->toBe('default/client'); +}); + +test('getPackageSharedHtmlPath resolves the shared/html sibling for a package-shaped code', function (): void { + $service = new Service(); + + $result = $service->getPackageSharedHtmlPath('default/admin'); + expect($result)->toBe(Path::join(PATH_THEMES, 'default', 'shared', 'html')); + + $result = $service->getPackageSharedHtmlPath('default/client'); + expect($result)->toBe(Path::join(PATH_THEMES, 'default', 'shared', 'html')); +}); + +test('getPackageSharedHtmlPath returns null for a flat, non-package code', function (): void { + $service = new Service(); + + expect($service->getPackageSharedHtmlPath('some-flat-theme'))->toBeNull(); +}); + +// $layout: top-level dir name => list of relative subpaths to create as +// directories, e.g. ['html'] for a flat theme, ['admin/html', 'client/html'] +// for a package. getThemes() enumerates a real filesystem path, so this +// builds a temp root independent of the production `default` package. +function makeFixtureThemesRoot(array $layout): string +{ + $filesystem = new Symfony\Component\Filesystem\Filesystem(); + $root = Path::join(sys_get_temp_dir(), 'fossbilling-get-themes-test-' . bin2hex(random_bytes(8))); + + foreach ($layout as $name => $subpaths) { + foreach ($subpaths as $subpath) { + $filesystem->mkdir(Path::join($root, $name, $subpath)); + } + } + + return $root; +} + +// buildThemeConfig() (called per theme getThemes() lists) needs 'extension' +// mod_service to return a real array from getCoreAndActiveModules(), or +// array_unique() there would be handed null. +function themesRootDi(): Pimple\Container +{ + $extensionServiceMock = Mockery::mock()->shouldIgnoreMissing(); + $extensionServiceMock->shouldReceive('getCoreAndActiveModules')->andReturn([]); + + $di = container(); + $di['mod_service'] = $di->protect(moduleService(['extension' => $extensionServiceMock])); + + return $di; +} + +test('getThemes lists a package with only an admin area in the admin bucket only', function (): void { + $root = makeFixtureThemesRoot(['mypackage' => ['admin/html']]); + + $serviceMock = Mockery::mock(Service::class)->makePartial(); + $serviceMock->shouldReceive('getThemesPath')->andReturn($root); + $serviceMock->setDi(themesRootDi()); + + expect(array_column($serviceMock->getThemes(false), 'code'))->toBe(['mypackage/admin']); + expect($serviceMock->getThemes(true))->toBe([]); + + (new Symfony\Component\Filesystem\Filesystem())->remove($root); +}); + +test('getThemes lists a package with only a client area in the client bucket only', function (): void { + $root = makeFixtureThemesRoot(['mypackage' => ['client/html']]); + + $serviceMock = Mockery::mock(Service::class)->makePartial(); + $serviceMock->shouldReceive('getThemesPath')->andReturn($root); + $serviceMock->setDi(themesRootDi()); + + expect(array_column($serviceMock->getThemes(true), 'code'))->toBe(['mypackage/client']); + expect($serviceMock->getThemes(false))->toBe([]); + + (new Symfony\Component\Filesystem\Filesystem())->remove($root); +}); + +test('getThemes lists a package with both areas in both buckets', function (): void { + $root = makeFixtureThemesRoot(['mypackage' => ['admin/html', 'client/html']]); + + $serviceMock = Mockery::mock(Service::class)->makePartial(); + $serviceMock->shouldReceive('getThemesPath')->andReturn($root); + $serviceMock->setDi(themesRootDi()); + + expect(array_column($serviceMock->getThemes(false), 'code'))->toBe(['mypackage/admin']); + expect(array_column($serviceMock->getThemes(true), 'code'))->toBe(['mypackage/client']); + + (new Symfony\Component\Filesystem\Filesystem())->remove($root); +}); + +test('getThemes still classifies a flat theme by its name, unaffected by the package shape', function (): void { + $root = makeFixtureThemesRoot([ + 'my-admin-theme' => ['html'], + 'my-client-theme' => ['html'], + ]); + + $serviceMock = Mockery::mock(Service::class)->makePartial(); + $serviceMock->shouldReceive('getThemesPath')->andReturn($root); + $serviceMock->setDi(themesRootDi()); + + expect(array_column($serviceMock->getThemes(false), 'code'))->toBe(['my-admin-theme']); + expect(array_column($serviceMock->getThemes(true), 'code'))->toBe(['my-client-theme']); + + (new Symfony\Component\Filesystem\Filesystem())->remove($root); }); diff --git a/src/themes/admin_default/html/partial_company_logo.html.twig b/src/themes/admin_default/html/partial_company_logo.html.twig deleted file mode 100644 index ea87aea94..000000000 --- a/src/themes/admin_default/html/partial_company_logo.html.twig +++ /dev/null @@ -1,16 +0,0 @@ -{% set logo_url = logo_url|default(company.logo_url) %} -{% set dark_logo_url = dark_logo_url|default(company.logo_url_dark) %} -{% set link_url = link_url|default('/'|url) %} -{% set link_class = link_class|default('') %} -{% set image_class = image_class|default('') %} -{% set image_style = image_style|default(null) %} -{% set fallback_class = fallback_class|default('') %} - - - {% if logo_url or dark_logo_url %} - {{ company.name }} - {{ company.name }} - {% else %} - {{ company.name }} - {% endif %} - diff --git a/src/themes/admin_default/.gitignore b/src/themes/default/admin/.gitignore similarity index 100% rename from src/themes/admin_default/.gitignore rename to src/themes/default/admin/.gitignore diff --git a/src/themes/admin_default/assets/css/vendor.css b/src/themes/default/admin/assets/css/vendor.css similarity index 100% rename from src/themes/admin_default/assets/css/vendor.css rename to src/themes/default/admin/assets/css/vendor.css diff --git a/src/themes/admin_default/assets/fossbilling.ts b/src/themes/default/admin/assets/fossbilling.ts similarity index 100% rename from src/themes/admin_default/assets/fossbilling.ts rename to src/themes/default/admin/assets/fossbilling.ts diff --git a/src/themes/admin_default/assets/js/datepicker.ts b/src/themes/default/admin/assets/js/datepicker.ts similarity index 100% rename from src/themes/admin_default/assets/js/datepicker.ts rename to src/themes/default/admin/assets/js/datepicker.ts diff --git a/src/themes/admin_default/assets/js/fossbilling.ts b/src/themes/default/admin/assets/js/fossbilling.ts similarity index 100% rename from src/themes/admin_default/assets/js/fossbilling.ts rename to src/themes/default/admin/assets/js/fossbilling.ts diff --git a/src/themes/admin_default/assets/js/tomselect.ts b/src/themes/default/admin/assets/js/tomselect.ts similarity index 100% rename from src/themes/admin_default/assets/js/tomselect.ts rename to src/themes/default/admin/assets/js/tomselect.ts diff --git a/src/themes/admin_default/assets/js/ui/backToTop.ts b/src/themes/default/admin/assets/js/ui/backToTop.ts similarity index 100% rename from src/themes/admin_default/assets/js/ui/backToTop.ts rename to src/themes/default/admin/assets/js/ui/backToTop.ts diff --git a/src/themes/admin_default/assets/js/ui/charts.ts b/src/themes/default/admin/assets/js/ui/charts.ts similarity index 100% rename from src/themes/admin_default/assets/js/ui/charts.ts rename to src/themes/default/admin/assets/js/ui/charts.ts diff --git a/src/themes/admin_default/assets/js/ui/modals.ts b/src/themes/default/admin/assets/js/ui/modals.ts similarity index 100% rename from src/themes/admin_default/assets/js/ui/modals.ts rename to src/themes/default/admin/assets/js/ui/modals.ts diff --git a/src/themes/admin_default/assets/js/ui/theme_settings.ts b/src/themes/default/admin/assets/js/ui/theme_settings.ts similarity index 100% rename from src/themes/admin_default/assets/js/ui/theme_settings.ts rename to src/themes/default/admin/assets/js/ui/theme_settings.ts diff --git a/src/themes/admin_default/assets/js/utils.ts b/src/themes/default/admin/assets/js/utils.ts similarity index 100% rename from src/themes/admin_default/assets/js/utils.ts rename to src/themes/default/admin/assets/js/utils.ts diff --git a/src/themes/admin_default/assets/scss/_flags.scss b/src/themes/default/admin/assets/scss/_flags.scss similarity index 90% rename from src/themes/admin_default/assets/scss/_flags.scss rename to src/themes/default/admin/assets/scss/_flags.scss index a724f4304..73aedc170 100644 --- a/src/themes/admin_default/assets/scss/_flags.scss +++ b/src/themes/default/admin/assets/scss/_flags.scss @@ -1,6 +1,6 @@ @use "flag-icons/sass/variables" as flagicons-variables; -$flag-icons-path: "../../../../../node_modules/flag-icons/flags"; +$flag-icons-path: "../../../../../../node_modules/flag-icons/flags"; .flag { position: relative; diff --git a/src/themes/admin_default/assets/scss/_theme_settings.scss b/src/themes/default/admin/assets/scss/_theme_settings.scss similarity index 100% rename from src/themes/admin_default/assets/scss/_theme_settings.scss rename to src/themes/default/admin/assets/scss/_theme_settings.scss diff --git a/src/themes/admin_default/assets/scss/fossbilling.scss b/src/themes/default/admin/assets/scss/fossbilling.scss similarity index 95% rename from src/themes/admin_default/assets/scss/fossbilling.scss rename to src/themes/default/admin/assets/scss/fossbilling.scss index 35dadbd64..0321dbe45 100644 --- a/src/themes/admin_default/assets/scss/fossbilling.scss +++ b/src/themes/default/admin/assets/scss/fossbilling.scss @@ -1,5 +1,6 @@ @use "flags"; @use "theme_settings"; +@use "../../../../../../frontend/styles/shared-theme"; .sticky-nav .nav-link { color: var(--tblr-body-color) !important; @@ -283,12 +284,6 @@ body { padding: 0 0 0.85rem; } -.fb-phone-country-code { - width: 5.5rem; - flex: 0 0 5.5rem; - max-width: 5.5rem; -} - @media (min-width: 992px) { .new-client-modal-dialog { max-width: min(820px, calc(100vw - 2rem)); @@ -334,15 +329,7 @@ body { } .db-avatar { - display: inline-block; - flex-shrink: 0; - vertical-align: middle; background-color: var(--tblr-bg-surface); - background-size: 100% 100%; - background-position: center; - background-repeat: no-repeat; - border-radius: 50%; - overflow: hidden; } @media (min-width: 992px) { diff --git a/src/themes/admin_default/custom-icons/iPlugin.svg b/src/themes/default/admin/custom-icons/iPlugin.svg similarity index 100% rename from src/themes/admin_default/custom-icons/iPlugin.svg rename to src/themes/default/admin/custom-icons/iPlugin.svg diff --git a/src/themes/admin_default/esbuild.mts b/src/themes/default/admin/esbuild.mts similarity index 84% rename from src/themes/admin_default/esbuild.mts rename to src/themes/default/admin/esbuild.mts index 5433c7dce..8211b0384 100644 --- a/src/themes/admin_default/esbuild.mts +++ b/src/themes/default/admin/esbuild.mts @@ -7,12 +7,12 @@ import { prepareThemeBuildDirs, sharedLoaders, writeAssetManifest, -} from '../../../frontend/tools/esbuild-helpers.mts'; -import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mts'; +} from '../../../../frontend/tools/esbuild-helpers.mts'; +import { buildIconSprite } from '../../../../frontend/tools/icon-sprite.mts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const isProduction = process.env.NODE_ENV === 'production'; -const rootDir = resolve(__dirname, '../../..'); +const rootDir = resolve(__dirname, '../../../..'); const nodeModulesDir = resolve(rootDir, 'node_modules'); const adminLoaders = { ...sharedLoaders, '.svg': 'dataurl' } as const; @@ -72,10 +72,10 @@ async function build() { }); await writeAssetManifest(paths.buildDir, { - 'build/fossbilling.js': '/themes/admin_default/assets/build/js/fossbilling.js', - 'build/vendor.css': '/themes/admin_default/assets/build/css/vendor.css', - 'build/fossbilling.css': '/themes/admin_default/assets/build/css/fossbilling.css', - 'build/symbol/icons-sprite.svg': '/themes/admin_default/assets/build/symbol/icons-sprite.svg', + 'build/fossbilling.js': '/themes/default/admin/assets/build/js/fossbilling.js', + 'build/vendor.css': '/themes/default/admin/assets/build/css/vendor.css', + 'build/fossbilling.css': '/themes/default/admin/assets/build/css/fossbilling.css', + 'build/symbol/icons-sprite.svg': '/themes/default/admin/assets/build/symbol/icons-sprite.svg', }); const duration = ((Date.now() - startTime) / 1000).toFixed(2); diff --git a/src/themes/admin_default/html/error.html.twig b/src/themes/default/admin/html/error.html.twig similarity index 100% rename from src/themes/admin_default/html/error.html.twig rename to src/themes/default/admin/html/error.html.twig diff --git a/src/themes/admin_default/html/layout_blank.html.twig b/src/themes/default/admin/html/layout_blank.html.twig similarity index 100% rename from src/themes/admin_default/html/layout_blank.html.twig rename to src/themes/default/admin/html/layout_blank.html.twig diff --git a/src/themes/admin_default/html/layout_default.html.twig b/src/themes/default/admin/html/layout_default.html.twig similarity index 99% rename from src/themes/admin_default/html/layout_default.html.twig rename to src/themes/default/admin/html/layout_default.html.twig index 024203ea5..e1e43dc54 100644 --- a/src/themes/admin_default/html/layout_default.html.twig +++ b/src/themes/default/admin/html/layout_default.html.twig @@ -60,6 +60,8 @@

{{ include('partial_company_logo.html.twig', { company: company, + logo_url: company.logo_url, + dark_logo_url: company.logo_url_dark, image_class: 'navbar-brand-image' }) }}

diff --git a/src/themes/admin_default/html/layout_login.html.twig b/src/themes/default/admin/html/layout_login.html.twig similarity index 100% rename from src/themes/admin_default/html/layout_login.html.twig rename to src/themes/default/admin/html/layout_login.html.twig diff --git a/src/themes/admin_default/html/macro_functions.html.twig b/src/themes/default/admin/html/macro_functions.html.twig similarity index 66% rename from src/themes/admin_default/html/macro_functions.html.twig rename to src/themes/default/admin/html/macro_functions.html.twig index a20705ef2..6c6a95caa 100644 --- a/src/themes/admin_default/html/macro_functions.html.twig +++ b/src/themes/default/admin/html/macro_functions.html.twig @@ -76,64 +76,7 @@ {% endmacro build_form %} {% macro status_name(status) %} - {% if status is same as(null) or (status is empty and status != '0') %} - - - {% else %} - {% set status = status|replace({'_': " "})|title|trim %} - {% if status == 'Active' %} - {{ 'Active'|trans }} - {% elseif status == 'Pending Setup' %} - {{ 'Pending Setup'|trans }} - {% elseif status == 'Failed Setup' %} - {{ 'Failed Setup'|trans }} - {% elseif status == 'Failed Renew' %} - {{ 'Failed Renewal'|trans }} - {% elseif status == 'Suspended' %} - {{ 'Suspended'|trans }} - {% elseif status == 'Canceled' %} - {{ 'Canceled'|trans }} - {% elseif status == 'Inactive' %} - {{ 'Inactive'|trans }} - {% elseif status == 'Enabled' %} - {{ 'Enabled'|trans }} - {% elseif status == 'Disabled' %} - {{ 'Disabled'|trans }} - {% elseif status == 'Draft' %} - {{ 'Draft'|trans }} - {% elseif status == 'Sent' %} - {{ 'Sent'|trans }} - {% elseif status == 'Sending' %} - {{ 'Sending'|trans }} - {% elseif status == 'Unsent' %} - {{ 'Unsent'|trans }} - {% elseif status == 'Paid' %} - {{ 'Paid'|trans }} - {% elseif status == 'Unpaid' %} - {{ 'Unpaid'|trans }} - {% elseif status == 'Refunded' %} - {{ 'Refunded'|trans }} - {% elseif status == 'Approved' %} - {{ 'Approved'|trans }} - {% elseif status == 'Processed' %} - {{ 'Processed'|trans }} - {% elseif status == 'Received' %} - {{ 'Received'|trans }} - {% elseif status == 'Error' %} - {{ 'Error'|trans }} - {% elseif status == 'Unknown' %} - {{ 'Unknown'|trans }} - {% elseif status == 'Complete' %} - {{ 'Complete'|trans }} - {% elseif status == 'Completed' %} - {{ 'Completed'|trans }} - {% elseif status == 'Success' %} - {{ 'Success'|trans }} - {% elseif status == 'Failed' %} - {{ 'Failed'|trans }} - {% else %} - {{ status|trans }} - {% endif %} - {% endif %} +{% include 'partial_status_name_shared.html.twig' with {status: status} %} {% endmacro status_name %} {% macro ip_lookup_link(ip) %} @@ -170,82 +113,8 @@ {% endmacro selectboxtld %} {% macro recaptcha() %} - -{% if guest.extension_is_on({"mod":"antispam"}) %} - {% set config = guest.antispam_recaptcha %} -{% endif %} - -{% if config is defined and config.enabled %} - {% set provider = config.captcha_provider|default('recaptcha_v2') %} - {% if provider == 'turnstile' %} - {% if config.turnstile_site_key %} -
- -
-
- {% endif %} - - {% elseif provider == 'recaptcha_v2' %} - {% if config.publickey %} -
- -
-
- {% endif %} - {% elseif provider == 'recaptcha_v3' %} - {% if config.publickey %} - - - - - {% endif %} - {% elseif provider == 'hcaptcha' %} - {% if config.hcaptcha_site_key %} -
- -
-
- {% endif %} - {% endif %} - {% endif %} -{% endmacro %} +{% include 'partial_recaptcha_shared.html.twig' %} +{% endmacro recaptcha %} {% macro table_search() %}
@@ -384,5 +253,5 @@ {% endmacro %} {% macro empty_list(colspan, message) %} -{{ (message|default('The list is empty'))|trans }} -{% endmacro %} +{% include 'partial_empty_list_shared.html.twig' with {colspan: colspan, message: message} %} +{% endmacro empty_list %} diff --git a/src/themes/admin_default/html/mod_index_dashboard.html.twig b/src/themes/default/admin/html/mod_index_dashboard.html.twig similarity index 100% rename from src/themes/admin_default/html/mod_index_dashboard.html.twig rename to src/themes/default/admin/html/mod_index_dashboard.html.twig diff --git a/src/themes/admin_default/html/partial_admin_auth_card.html.twig b/src/themes/default/admin/html/partial_admin_auth_card.html.twig similarity index 92% rename from src/themes/admin_default/html/partial_admin_auth_card.html.twig rename to src/themes/default/admin/html/partial_admin_auth_card.html.twig index fdb1d108e..0bd093bdd 100644 --- a/src/themes/admin_default/html/partial_admin_auth_card.html.twig +++ b/src/themes/default/admin/html/partial_admin_auth_card.html.twig @@ -8,6 +8,8 @@
{{ include('partial_company_logo.html.twig', { company: company, + logo_url: company.logo_url, + dark_logo_url: company.logo_url_dark, link_url: '/'|url(area: 'client'), image_style: 'height: 60px; width: auto;', fallback_class: 'h1' diff --git a/src/themes/admin_default/html/partial_admin_detail_tabs.html.twig b/src/themes/default/admin/html/partial_admin_detail_tabs.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_admin_detail_tabs.html.twig rename to src/themes/default/admin/html/partial_admin_detail_tabs.html.twig diff --git a/src/themes/admin_default/html/partial_admin_form_card.html.twig b/src/themes/default/admin/html/partial_admin_form_card.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_admin_form_card.html.twig rename to src/themes/default/admin/html/partial_admin_form_card.html.twig diff --git a/src/themes/admin_default/html/partial_admin_inner_tabs.html.twig b/src/themes/default/admin/html/partial_admin_inner_tabs.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_admin_inner_tabs.html.twig rename to src/themes/default/admin/html/partial_admin_inner_tabs.html.twig diff --git a/src/themes/admin_default/html/partial_admin_related_section.html.twig b/src/themes/default/admin/html/partial_admin_related_section.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_admin_related_section.html.twig rename to src/themes/default/admin/html/partial_admin_related_section.html.twig diff --git a/src/themes/admin_default/html/partial_admin_summary_card.html.twig b/src/themes/default/admin/html/partial_admin_summary_card.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_admin_summary_card.html.twig rename to src/themes/default/admin/html/partial_admin_summary_card.html.twig diff --git a/src/themes/admin_default/html/partial_batch_delete.html.twig b/src/themes/default/admin/html/partial_batch_delete.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_batch_delete.html.twig rename to src/themes/default/admin/html/partial_batch_delete.html.twig diff --git a/src/themes/admin_default/html/partial_bb_meta.html.twig b/src/themes/default/admin/html/partial_bb_meta.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_bb_meta.html.twig rename to src/themes/default/admin/html/partial_bb_meta.html.twig diff --git a/src/themes/admin_default/html/partial_dashboard_invoices_card.html.twig b/src/themes/default/admin/html/partial_dashboard_invoices_card.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_dashboard_invoices_card.html.twig rename to src/themes/default/admin/html/partial_dashboard_invoices_card.html.twig diff --git a/src/themes/admin_default/html/partial_dashboard_orders_card.html.twig b/src/themes/default/admin/html/partial_dashboard_orders_card.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_dashboard_orders_card.html.twig rename to src/themes/default/admin/html/partial_dashboard_orders_card.html.twig diff --git a/src/themes/admin_default/html/partial_dashboard_product_sales_card.html.twig b/src/themes/default/admin/html/partial_dashboard_product_sales_card.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_dashboard_product_sales_card.html.twig rename to src/themes/default/admin/html/partial_dashboard_product_sales_card.html.twig diff --git a/src/themes/admin_default/html/partial_email_preview.html.twig b/src/themes/default/admin/html/partial_email_preview.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_email_preview.html.twig rename to src/themes/default/admin/html/partial_email_preview.html.twig diff --git a/src/themes/admin_default/html/partial_extensions.html.twig b/src/themes/default/admin/html/partial_extensions.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_extensions.html.twig rename to src/themes/default/admin/html/partial_extensions.html.twig diff --git a/src/themes/admin_default/html/partial_menu_top.html.twig b/src/themes/default/admin/html/partial_menu_top.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_menu_top.html.twig rename to src/themes/default/admin/html/partial_menu_top.html.twig diff --git a/src/themes/admin_default/html/partial_pagination.html.twig b/src/themes/default/admin/html/partial_pagination.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_pagination.html.twig rename to src/themes/default/admin/html/partial_pagination.html.twig diff --git a/src/themes/admin_default/html/partial_pricing.html.twig b/src/themes/default/admin/html/partial_pricing.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_pricing.html.twig rename to src/themes/default/admin/html/partial_pricing.html.twig diff --git a/src/themes/admin_default/html/partial_record_selector.html.twig b/src/themes/default/admin/html/partial_record_selector.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_record_selector.html.twig rename to src/themes/default/admin/html/partial_record_selector.html.twig diff --git a/src/themes/admin_default/html/partial_search.html.twig b/src/themes/default/admin/html/partial_search.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_search.html.twig rename to src/themes/default/admin/html/partial_search.html.twig diff --git a/src/themes/admin_default/html/partial_search_action.html.twig b/src/themes/default/admin/html/partial_search_action.html.twig similarity index 100% rename from src/themes/admin_default/html/partial_search_action.html.twig rename to src/themes/default/admin/html/partial_search_action.html.twig diff --git a/src/themes/admin_default/icon-manifest.json b/src/themes/default/admin/icon-manifest.json similarity index 100% rename from src/themes/admin_default/icon-manifest.json rename to src/themes/default/admin/icon-manifest.json diff --git a/src/themes/admin_default/manifest.json b/src/themes/default/admin/manifest.json similarity index 100% rename from src/themes/admin_default/manifest.json rename to src/themes/default/admin/manifest.json diff --git a/src/themes/admin_default/package.json b/src/themes/default/admin/package.json similarity index 100% rename from src/themes/admin_default/package.json rename to src/themes/default/admin/package.json diff --git a/src/themes/admin_default/screenshot.jpg b/src/themes/default/admin/screenshot.jpg similarity index 100% rename from src/themes/admin_default/screenshot.jpg rename to src/themes/default/admin/screenshot.jpg diff --git a/src/themes/huraga/assets/css/vendor.css b/src/themes/default/client/assets/css/vendor.css similarity index 100% rename from src/themes/huraga/assets/css/vendor.css rename to src/themes/default/client/assets/css/vendor.css diff --git a/src/themes/huraga/assets/huraga.ts b/src/themes/default/client/assets/huraga.ts similarity index 100% rename from src/themes/huraga/assets/huraga.ts rename to src/themes/default/client/assets/huraga.ts diff --git a/src/themes/huraga/assets/js/phone-input.ts b/src/themes/default/client/assets/js/phone-input.ts similarity index 100% rename from src/themes/huraga/assets/js/phone-input.ts rename to src/themes/default/client/assets/js/phone-input.ts diff --git a/src/themes/huraga/assets/js/tomselect.ts b/src/themes/default/client/assets/js/tomselect.ts similarity index 100% rename from src/themes/huraga/assets/js/tomselect.ts rename to src/themes/default/client/assets/js/tomselect.ts diff --git a/src/themes/huraga/assets/js/ui/theme.ts b/src/themes/default/client/assets/js/ui/theme.ts similarity index 100% rename from src/themes/huraga/assets/js/ui/theme.ts rename to src/themes/default/client/assets/js/ui/theme.ts diff --git a/src/themes/huraga/assets/js/utils.ts b/src/themes/default/client/assets/js/utils.ts similarity index 100% rename from src/themes/huraga/assets/js/utils.ts rename to src/themes/default/client/assets/js/utils.ts diff --git a/src/themes/huraga/assets/scss/_flags.scss b/src/themes/default/client/assets/scss/_flags.scss similarity index 89% rename from src/themes/huraga/assets/scss/_flags.scss rename to src/themes/default/client/assets/scss/_flags.scss index 8d507b93d..764c6da5d 100644 --- a/src/themes/huraga/assets/scss/_flags.scss +++ b/src/themes/default/client/assets/scss/_flags.scss @@ -1,6 +1,6 @@ @use "flag-icons/sass/variables" as flagicons-variables; -$flag-icons-path: "../../../../../node_modules/flag-icons/flags"; +$flag-icons-path: "../../../../../../node_modules/flag-icons/flags"; .fib { background-size: contain; diff --git a/src/themes/huraga/assets/scss/huraga.scss b/src/themes/default/client/assets/scss/huraga.scss similarity index 96% rename from src/themes/huraga/assets/scss/huraga.scss rename to src/themes/default/client/assets/scss/huraga.scss index 4629b4fc2..8a039a24a 100644 --- a/src/themes/huraga/assets/scss/huraga.scss +++ b/src/themes/default/client/assets/scss/huraga.scss @@ -1,4 +1,5 @@ @use "flags"; +@use "../../../../../../frontend/styles/shared-theme"; html { font-size: 1rem; @@ -147,12 +148,6 @@ body { } } -.fb-phone-country-code { - width: 5.5rem; - flex: 0 0 5.5rem; - max-width: 5.5rem; -} - .iti { --iti-border-color: var(--bs-border-color); --iti-country-selector-bg: var(--bs-body-bg); @@ -412,13 +407,5 @@ body { } .db-avatar { - display: inline-block; - flex-shrink: 0; - vertical-align: middle; background-color: var(--bs-secondary-bg); - background-size: 100% 100%; - background-position: center; - background-repeat: no-repeat; - border-radius: 50%; - overflow: hidden; } diff --git a/src/themes/huraga/config/settings.html.twig b/src/themes/default/client/config/settings.html.twig similarity index 100% rename from src/themes/huraga/config/settings.html.twig rename to src/themes/default/client/config/settings.html.twig diff --git a/src/themes/huraga/config/settings_data.json.example b/src/themes/default/client/config/settings_data.json.example similarity index 100% rename from src/themes/huraga/config/settings_data.json.example rename to src/themes/default/client/config/settings_data.json.example diff --git a/src/themes/huraga/custom-icons/.gitkeep b/src/themes/default/client/custom-icons/.gitkeep similarity index 100% rename from src/themes/huraga/custom-icons/.gitkeep rename to src/themes/default/client/custom-icons/.gitkeep diff --git a/src/themes/huraga/esbuild.mts b/src/themes/default/client/esbuild.mts similarity index 81% rename from src/themes/huraga/esbuild.mts rename to src/themes/default/client/esbuild.mts index 16d3b189a..5bc6b7124 100644 --- a/src/themes/huraga/esbuild.mts +++ b/src/themes/default/client/esbuild.mts @@ -7,13 +7,13 @@ import { prepareThemeBuildDirs, sharedLoaders, writeAssetManifest, -} from '../../../frontend/tools/esbuild-helpers.mts'; -import { buildIconSprite } from '../../../frontend/tools/icon-sprite.mts'; +} from '../../../../frontend/tools/esbuild-helpers.mts'; +import { buildIconSprite } from '../../../../frontend/tools/icon-sprite.mts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const isProduction = process.env.NODE_ENV === 'production'; const purgeSafelist = [/^hide-/, /^iti/]; -const rootDir = resolve(__dirname, '../../..'); +const rootDir = resolve(__dirname, '../../../..'); const nodeModulesDir = resolve(rootDir, 'node_modules'); async function build() { @@ -73,10 +73,10 @@ async function build() { }); await writeAssetManifest(paths.buildDir, { - 'build/huraga.js': '/themes/huraga/assets/build/js/huraga.js', - 'build/vendor.css': '/themes/huraga/assets/build/css/vendor.css', - 'build/huraga.css': '/themes/huraga/assets/build/css/huraga.css', - 'build/symbol/icons-sprite.svg': '/themes/huraga/assets/build/symbol/icons-sprite.svg', + 'build/huraga.js': '/themes/default/client/assets/build/js/huraga.js', + 'build/vendor.css': '/themes/default/client/assets/build/css/vendor.css', + 'build/huraga.css': '/themes/default/client/assets/build/css/huraga.css', + 'build/symbol/icons-sprite.svg': '/themes/default/client/assets/build/symbol/icons-sprite.svg', }); const duration = ((Date.now() - startTime) / 1000).toFixed(2); diff --git a/src/themes/huraga/html/error.html.twig b/src/themes/default/client/html/error.html.twig similarity index 100% rename from src/themes/huraga/html/error.html.twig rename to src/themes/default/client/html/error.html.twig diff --git a/src/themes/huraga/html/layout_default.html.twig b/src/themes/default/client/html/layout_default.html.twig similarity index 99% rename from src/themes/huraga/html/layout_default.html.twig rename to src/themes/default/client/html/layout_default.html.twig index ba6cecfb9..4983afd6a 100644 --- a/src/themes/huraga/html/layout_default.html.twig +++ b/src/themes/default/client/html/layout_default.html.twig @@ -92,6 +92,7 @@ dark_logo_url: settings.show_company_logo ? company.logo_url_dark : null, link_class: 'navbar-brand', image_class: 'd-none d-sm-block', + image_height: '45px', show_mobile_name: true }) }}