From 206860fb753011fae2aeeb1881b7ff3477142c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 16 Jul 2026 20:13:38 +0300 Subject: [PATCH 01/17] fix(MethodTransformers): update getFormFields method to use data_get for improved null handling --- src/Repositories/Logic/MethodTransformers.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Repositories/Logic/MethodTransformers.php b/src/Repositories/Logic/MethodTransformers.php index d6dd4ed9a..5a8b7f708 100644 --- a/src/Repositories/Logic/MethodTransformers.php +++ b/src/Repositories/Logic/MethodTransformers.php @@ -307,8 +307,9 @@ public function getFormFields($object, $schema = [], $noSerialization = false) case 'text': case 'textarea': case 'input-date': + case 'input-phone': $found = true; - $acc[$item['name']] = $object->{$item['name']} ?? null; + $acc[$item['name']] = data_get($object, $item['name'], null) ?? null; break; default: From 7d0009c38fcb463f012cf2419ad83c66e686412f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:33:55 +0300 Subject: [PATCH 02/17] feat(MediaService): add getFrontendUrl method and include frontend URL in mediableFormat --- src/Entities/Media.php | 1 + src/Services/MediaLibrary/Local.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Entities/Media.php b/src/Entities/Media.php index c01f4231c..1477175f5 100755 --- a/src/Entities/Media.php +++ b/src/Entities/Media.php @@ -86,6 +86,7 @@ public function mediableFormat() 'name' => $this->filename, 'thumbnail' => ImageService::getCmsUrl($this->uuid, ['h' => '256']), 'original' => ImageService::getRawUrl($this->uuid), + 'frontend' => ImageService::getFrontendUrl($this->uuid), 'medium' => ImageService::getUrl($this->uuid, ['h' => '430']), 'width' => $this->width, 'height' => $this->height, diff --git a/src/Services/MediaLibrary/Local.php b/src/Services/MediaLibrary/Local.php index 53379a3cb..909ffe244 100755 --- a/src/Services/MediaLibrary/Local.php +++ b/src/Services/MediaLibrary/Local.php @@ -64,6 +64,21 @@ public function getCmsUrl($id, array $params = []) return $this->getRawUrl($id); } + /** + * @param string $id + * @param array $params + * @return string + */ + public function getFrontendUrl($id, array $params = []) + { + /** @var FilesystemAdapter $disk */ + $disk = Storage::disk(config(modularousBaseKey().'.media_library.disk')); + $storageUrl = $disk->url($id); + $endpoint = parse_url($storageUrl, PHP_URL_PATH) ?: $storageUrl; + + return rtrim(modularousConfig('app_url'), '/') . '/' . ltrim($endpoint, '/'); + } + /** * @param string $id * @return string From dab0c2bbca80e7c171a48e7d6df77e03cd67dec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:34:13 +0300 Subject: [PATCH 03/17] fix(InteractsWithAttachmentPayloads): enhance attachment role checks with data_get for improved null handling --- .../Traits/Concerns/InteractsWithAttachmentPayloads.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php index 75f2ecb74..388d55c74 100644 --- a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php +++ b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php @@ -79,6 +79,10 @@ protected function attachmentRoleIsPresentInFields(array $fields, string $role): return true; } + if(data_get($fields, $role) !== null) { + return true; + } + foreach (getLocales() as $locale) { if (array_key_exists($role, $fields[$locale] ?? [])) { return true; @@ -97,6 +101,10 @@ protected function getAttachmentPayloadForRole(array $fields, string $role): mix return $fields[$role]; } + if(($notation = data_get($fields, $role)) !== null) { + return $notation; + } + $nested = []; foreach (getLocales() as $locale) { if (array_key_exists($role, $fields[$locale] ?? [])) { From 38b598ee6d432faeb28613e4f5d9554820cf1242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:34:28 +0300 Subject: [PATCH 04/17] refactor(Traits): optimize detach methods for files and medias by using newPivotStatement for improved performance --- src/Repositories/Traits/FilesTrait.php | 18 ++++++++++-------- src/Repositories/Traits/ImagesTrait.php | 20 ++++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/Repositories/Traits/FilesTrait.php b/src/Repositories/Traits/FilesTrait.php index 6c4b604a3..a835a9f0b 100755 --- a/src/Repositories/Traits/FilesTrait.php +++ b/src/Repositories/Traits/FilesTrait.php @@ -101,17 +101,19 @@ public function afterSaveFilesTrait($object, $fields) */ private function detachFilesForRoleLocale($object, string $role, string $locale): void { - $relatedKey = $object->files()->getRelated()->getQualifiedKeyName(); - $ids = $object->files() - ->wherePivot('role', $role) - ->wherePivot('locale', $locale) - ->pluck($relatedKey); - - if ($ids->isEmpty()) { + $relation = $object->files(); + $deleted = $relation->newPivotStatement() + ->where($relation->getMorphType(), $object->getMorphClass()) + ->where($relation->getForeignPivotKeyName(), $object->getKey()) + ->where('role', $role) + ->where('locale', $locale) + ->delete(); + + if ($deleted === 0) { return; } - $object->files()->detach($ids->all()); + $object->unsetRelation('files'); $this->mustTouchEloquentModel(); } diff --git a/src/Repositories/Traits/ImagesTrait.php b/src/Repositories/Traits/ImagesTrait.php index be744b086..edb91287c 100755 --- a/src/Repositories/Traits/ImagesTrait.php +++ b/src/Repositories/Traits/ImagesTrait.php @@ -6,9 +6,11 @@ use Illuminate\Support\Collection; use Unusualify\Modularous\Entities\Media; use Unusualify\Modularous\Entities\Model; +use Unusualify\Modularous\Repositories\Traits\Concerns\InteractsWithAttachmentPayloads; trait ImagesTrait { + use InteractsWithAttachmentPayloads; /** * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveImagesTrait` during pending-only * revision saves so {@see afterSaveImagesTrait} is skipped. @@ -199,20 +201,21 @@ public function afterSaveImagesTrait($object, $fields) */ private function detachMediasForRoleLocale($object, string $role, string $locale): void { - $relatedKey = $object->medias()->getRelated()->getQualifiedKeyName(); - $relation = $object->medias()->wherePivot('role', $role); + $relation = $object->medias(); + $query = $relation->newPivotStatement() + ->where($relation->getMorphType(), $object->getMorphClass()) + ->where($relation->getForeignPivotKeyName(), $object->getKey()) + ->where('role', $role); if (modularousConfig('media_library.translated_form_fields', false)) { - $relation->wherePivot('locale', $locale); + $query->where('locale', $locale); } - $ids = $relation->pluck($relatedKey); - - if ($ids->isEmpty()) { + if ($query->delete() === 0) { return; } - $object->medias()->detach($ids->all()); + $object->unsetRelation('medias'); $this->mustTouchEloquentModel(); } @@ -242,9 +245,10 @@ private function attachImageSpecsFromRows($object, array $rows, string $role, st public function getFormFieldsImagesTrait($object, $fields, $schema) { $imageInputs = $this->getColumns(__TRAIT__); + if (! empty($imageInputs) && $object->has('medias')) { $schema = $schema ?? $this->inputs(); - $schema = $this->chunkInputs($schema, all: true, noGroupChunk: true); + $schema = $this->chunkInputs($schema, all: true, noGroupChunk: false); $mediasByRole = $object->medias->groupBy('pivot.role'); $default_locale = config('app.locale'); $fallback_locale = config('app.fallback_locale'); From 6c5de69ba37d43cbb8e676fd4ccd4a5dad50af9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:35:34 +0300 Subject: [PATCH 05/17] feat(SystemSetting): introduce comprehensive system settings module with SMTP configuration, analytics support, and migration for legacy robots.txt --- lang/en/modules.php | 5 + modules/SystemSetting/Config/config.php | 137 ++++++++++++------ modules/SystemSetting/Entities/General.php | 50 +++++-- .../SystemSettingServiceProvider.php | 76 ++++++++++ .../Repositories/GeneralRepository.php | 82 ++++++++++- .../Services/SystemSettingsService.php | 47 ++++++ .../Support/AnalyticsScripts.php | 132 +++++++++++++++++ .../Support/ApplySmtpMailConfig.php | 80 ++++++++++ .../MigrateRobotsTxtFromSiteSetting.php | 79 ++++++++++ .../Support/SystemSettingsInputMerger.php | 95 ++++++++++++ .../Transformers/GeneralResource.php | 21 ++- modules/SystemSetting/module.json | 2 +- 12 files changed, 741 insertions(+), 65 deletions(-) create mode 100644 modules/SystemSetting/Providers/SystemSettingServiceProvider.php create mode 100644 modules/SystemSetting/Services/SystemSettingsService.php create mode 100644 modules/SystemSetting/Support/AnalyticsScripts.php create mode 100644 modules/SystemSetting/Support/ApplySmtpMailConfig.php create mode 100644 modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php create mode 100644 modules/SystemSetting/Support/SystemSettingsInputMerger.php diff --git a/lang/en/modules.php b/lang/en/modules.php index e3cd91e84..15631bedf 100755 --- a/lang/en/modules.php +++ b/lang/en/modules.php @@ -107,4 +107,9 @@ 'name' => 'State | States | {n} State', ], ], + 'system_setting' => [ + 'general' => [ + 'name' => 'General Settings | General Settings | {n} General Settings', + ], + ], ]; diff --git a/modules/SystemSetting/Config/config.php b/modules/SystemSetting/Config/config.php index 2cf8f658f..325dce1dd 100644 --- a/modules/SystemSetting/Config/config.php +++ b/modules/SystemSetting/Config/config.php @@ -8,68 +8,115 @@ 'routes' => [ 'general' => [ 'name' => 'General', - 'headline' => 'Generals', + 'headline' => 'General Settings', 'url' => 'generals', 'route_name' => 'general', 'icon' => '$submodule', - 'title_column_key' => 'name', - 'table_options' => [ - 'createOnModal' => true, - 'editOnModal' => true, - 'isRowEditing' => false, - 'rowActionsType' => 'inline', - ], - 'headers' => [ + 'title_column_key' => 'site.name', + 'inputs' => [ [ - 'title' => 'Name', - 'key' => 'name', - 'formatter' => [ - 'edit', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'SMTP', + 'name' => 'smtp', + 'col' => ['cols' => 12], + 'schema' => [ + ['name' => 'host', 'type' => 'text', 'label' => 'Host', 'placeholder' => 'smtp.example.com', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'port', 'type' => 'number-input', 'label' => 'Port', 'placeholder' => 587, 'rules' => 'nullable|integer', 'col' => ['cols' => 12, 'lg' => 6]], + [ + 'name' => 'encryption', + 'type' => 'select', + 'label' => 'Encryption', + 'col' => ['cols' => 12, 'lg' => 6], + 'returnObject' => false, + 'itemTitle' => 'title', + 'itemValue' => 'value', + 'setFirstDefault' => true, + 'default' => 'tls', + 'items' => [ + ['title' => 'TLS', 'value' => 'tls'], + ['title' => 'SSL', 'value' => 'ssl'], + ], + 'rules' => 'nullable|string|in:,tls,ssl', + ], + ['name' => 'username', 'type' => 'text', 'label' => 'Username', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'password', 'type' => 'password', 'label' => 'Password', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'from_address', 'type' => 'text', 'label' => 'From address', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'from_name', 'type' => 'text', 'label' => 'From name', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], ], - 'searchable' => true, ], [ - 'title' => 'Created Time', - 'key' => 'created_at', - 'formatter' => [ - 'date', - 'long', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Contact', + 'name' => 'contact', + 'schema' => [ + ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], ], - 'searchable' => true, ], [ - 'title' => 'Actions', - 'key' => 'actions', - 'sortable' => false, + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Locale & region', + 'name' => 'locale', + 'schema' => [ + ['name' => 'timezone', 'type' => 'text', 'label' => 'Timezone', 'rules' => 'nullable|string|max:64'], + ['name' => 'default_currency', 'type' => 'text', 'label' => 'Default currency', 'rules' => 'nullable|string|max:8'], + ['name' => 'date_format', 'type' => 'text', 'label' => 'Date format', 'rules' => 'nullable|string|max:32'], + ], ], - ], - 'inputs' => [ [ - 'name' => 'name', - 'label' => 'Name', - 'type' => 'text', + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Analytics', + 'name' => 'analytics', + 'schema' => [ + [ + 'name' => 'enabled', + 'type' => 'switch', + 'label' => 'Enable analytics', + 'default' => false, + 'hideDetails' => 'auto', + 'rules' => 'nullable|boolean', + 'col' => ['cols' => 12], + ], + ['name' => 'gtm_id', 'type' => 'text', 'label' => 'GTM container ID', 'placeholder' => 'GTM-XXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'ga_measurement_id', 'type' => 'text', 'label' => 'GA measurement ID', 'placeholder' => 'G-XXXXXXXXXX', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ], ], - // [ - // 'name' => 'balance', - // 'label' => 'Balance', - // 'type' => 'text', - // 'spreadable' => true, - // ], + [ - 'name' => 'cont', - 'label' => 'Spread', - 'type' => 'spread', - 'connector' => 'SystemSetting:General', - 'reservedKeys' => ['name'], - 'height' => '250px', - // 'scrollable' => true, + 'type' => '@collapsible_group', + 'name' => 'site', + 'typeIntTitle' => 'Site', + 'schema' => [ + ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'], + ], ], [ - 'name' => 'logo', - 'label' => 'Logo', - 'type' => 'image', - 'translated' => true, + 'type' => '@collapsible_wrap', + 'typeIntTitle' => 'Social Links', + 'schema' => [ + ['type' => '@system_social_links'], + ], ], + [ + 'type' => '@collapsible_group', + 'name' => 'seo', + 'typeIntTitle' => 'SEO', + 'schema' => [ + ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'], + ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'], + ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'], + ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'], + ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'], + ], + ] ], ], ], diff --git a/modules/SystemSetting/Entities/General.php b/modules/SystemSetting/Entities/General.php index 5e8a9e60c..ffb179366 100644 --- a/modules/SystemSetting/Entities/General.php +++ b/modules/SystemSetting/Entities/General.php @@ -1,33 +1,51 @@ - */ - use HasImages, HasSpreadable, IsSingular; + use HasImages, IsSingular, HasRepeaters; /** - * Override the default `spread_payload` so the trait's - * `getSpreadableSavingKey()` returns `cont`, matching the input - * name in Config/config.php and the entry in `$fillable`. Without - * this, SpreadHydrate would rewrite the input name to - * `spread_payload` and the form column would silently desync. + * @var list */ - protected static $spreadableSavingKey = 'cont'; - protected $fillable = [ - 'name', 'published', - 'cont', + 'site', + 'social', + 'contact', + 'seo', + 'smtp', + 'analytics', + 'locale', ]; + + /** + * @var array + */ + protected $casts = [ + 'site' => 'array', + 'social' => 'array', + 'contact' => 'array', + 'seo' => 'array', + 'smtp' => 'array', + 'analytics' => 'array', + 'locale' => 'array', + 'published' => 'boolean', + ]; + + /** + * @return list + */ + public static function settingsSections(): array + { + return ['site', 'social', 'contact', 'seo', 'smtp', 'analytics', 'locale']; + } } diff --git a/modules/SystemSetting/Providers/SystemSettingServiceProvider.php b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php new file mode 100644 index 000000000..5e3d4a349 --- /dev/null +++ b/modules/SystemSetting/Providers/SystemSettingServiceProvider.php @@ -0,0 +1,76 @@ +app->singleton(SystemSettingsService::class); + $this->app->alias(SystemSettingsService::class, 'system.settings'); + + $this->app->singleton(AnalyticsScripts::class); + $this->app->singleton(ApplySmtpMailConfig::class); + $this->app->singleton(SystemSettingsInputMerger::class); + $this->app->singleton(MigrateRobotsTxtFromSiteSetting::class); + } + + public function boot(): void + { + $this->registerInputAliases(); + $this->mergeRouteInputs(); + + $this->app->make(ApplySmtpMailConfig::class)->apply(); + try { + } catch (\Throwable) { + // Mail override is best-effort when settings / DB are unavailable. + } + + if ($this->app->runningInConsole()) { + return; + } + + try { + $this->app->make(MigrateRobotsTxtFromSiteSetting::class)->migrateIfNeeded(); + } catch (\Throwable) { + // Migration is best-effort when CMS tables are absent. + } + } + + protected function mergeRouteInputs(): void + { + $module = Modularous::find('SystemSetting'); + + if ($module === null) { + return; + } + + $defaultInputs = (array) $module->getConfig('routes.general.inputs', []); + $merged = $this->app->make(SystemSettingsInputMerger::class)->merge($defaultInputs); + + $module->setConfig($merged, 'routes.general.inputs'); + } + + protected function registerInputAliases(): void + { + $aliases = (array) modularousConfig('system_settings.input_aliases', []); + + foreach ($aliases as $alias => $definition) { + if (! is_string($alias) || ! is_array($definition)) { + continue; + } + + config(["modularous.input_types.{$alias}" => $definition]); + } + } +} diff --git a/modules/SystemSetting/Repositories/GeneralRepository.php b/modules/SystemSetting/Repositories/GeneralRepository.php index a070964b9..aca9c5186 100644 --- a/modules/SystemSetting/Repositories/GeneralRepository.php +++ b/modules/SystemSetting/Repositories/GeneralRepository.php @@ -1,17 +1,97 @@ model = $model; } + + public function prepareFieldsBeforeCreate($fields) + { + $fields = $this->encryptSmtpPassword($fields); + + return parent::prepareFieldsBeforeCreate($fields); + } + + /** + * @param Model $object + * @param array $fields + * @return array + */ + public function prepareFieldsBeforeSave($object, $fields) + { + $fields = $this->encryptSmtpPassword($fields, $object); + + return parent::prepareFieldsBeforeSave($object, $fields); + } + + /** + * @param Model $object + * @param array $fields + */ + public function afterSave($object, $fields): void + { + parent::afterSave($object, $fields); + + if (app()->bound('system.settings')) { + app('system.settings')->forgetCache(); + } + + if (app()->bound('site.settings')) { + app('site.settings')->forgetCache(); + } + } + + /** + * @param array $fields + * @return array + */ + protected function encryptSmtpPassword(array $fields, mixed $object = null): array + { + $password = Arr::get($fields, 'smtp.password'); + + if ($password === null) { + return $fields; + } + + if ($password === '') { + if ($object !== null) { + $existing = is_array($object->smtp ?? null) ? $object->smtp : []; + Arr::set($fields, 'smtp.password', $existing['password'] ?? null); + } + + return $fields; + } + + if (is_string($password) && ! $this->alreadyEncrypted($password)) { + Arr::set($fields, 'smtp.password', encrypt($password, false)); + } + + return $fields; + } + + protected function alreadyEncrypted(string $value): bool + { + try { + decrypt($value, false); + + return true; + } catch (\Throwable) { + return false; + } + } } diff --git a/modules/SystemSetting/Services/SystemSettingsService.php b/modules/SystemSetting/Services/SystemSettingsService.php new file mode 100644 index 000000000..118bbde21 --- /dev/null +++ b/modules/SystemSetting/Services/SystemSettingsService.php @@ -0,0 +1,47 @@ +generalRepository; + } + + protected function settingsSections(): array + { + return General::settingsSections(); + } +} diff --git a/modules/SystemSetting/Support/AnalyticsScripts.php b/modules/SystemSetting/Support/AnalyticsScripts.php new file mode 100644 index 000000000..7a93c3cf3 --- /dev/null +++ b/modules/SystemSetting/Support/AnalyticsScripts.php @@ -0,0 +1,132 @@ +settings->get('analytics.enabled'); + + if ($db === null || $db === '') { + return (bool) modularousConfig( + 'system_settings.analytics_enabled_default', + env('MODULAROUS_ANALYTICS_ENABLED', false) + ); + } + + return filter_var($db, FILTER_VALIDATE_BOOLEAN); + } + + public function gtmId(): ?string + { + return $this->normalizeId( + $this->settings->get('analytics.gtm_id'), + '/^GTM-[A-Z0-9]+$/i' + ); + } + + public function gaMeasurementId(): ?string + { + return $this->normalizeId( + $this->settings->get('analytics.ga_measurement_id'), + '/^G-[A-Z0-9]+$/i' + ); + } + + /** + * GTM bootstrap + optional gtag.js for GA4 (only when measurement ID is set). + */ + public function headHtml(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $parts = []; + + $gtmId = $this->gtmId(); + if ($gtmId !== null) { + $escaped = e($gtmId); + $parts[] = << + + +HTML; + } + + $gaId = $this->gaMeasurementId(); + if ($gaId !== null) { + $escaped = e($gaId); + $parts[] = << + + +HTML; + } + + return implode("\n", $parts); + } + + /** + * GTM noscript iframe for immediately after the opening body tag. + */ + public function bodyOpenHtml(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $gtmId = $this->gtmId(); + if ($gtmId === null) { + return ''; + } + + $escaped = e($gtmId); + + return << + + +HTML; + } + + protected function normalizeId(mixed $value, string $pattern): ?string + { + if (! is_string($value) && ! is_numeric($value)) { + return null; + } + + $id = trim((string) $value); + if ($id === '' || ! preg_match($pattern, $id)) { + return null; + } + + return strtoupper($id); + } +} diff --git a/modules/SystemSetting/Support/ApplySmtpMailConfig.php b/modules/SystemSetting/Support/ApplySmtpMailConfig.php new file mode 100644 index 000000000..78b3b92d6 --- /dev/null +++ b/modules/SystemSetting/Support/ApplySmtpMailConfig.php @@ -0,0 +1,80 @@ + 'smtp', + 'host' => $host, + 'port' => is_numeric($port) ? (int) $port : null, + 'username' => is_string($username) && $username !== '' ? $username : null, + 'password' => is_string($password) && $password !== '' ? $password : null, + // 'scheme' => $this->mapEncryptionToScheme($encryption), + ], static fn ($value) => $value !== null); + + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp' => array_merge( + (array) config('mail.mailers.smtp', []), + $mailerConfig, + ), + ]); + + if (is_string($fromAddress) && $fromAddress !== '') { + config(['mail.from.address' => $fromAddress]); + } + + if (is_string($fromName) && $fromName !== '') { + config(['mail.from.name' => $fromName]); + } + } + + /** + * Map admin encryption values to Laravel SMTP mailer schemes. + * + * Laravel only accepts "smtp" and "smtps" as schemes. + */ + private function mapEncryptionToScheme(mixed $encryption): ?string + { + if (! is_string($encryption) || $encryption === '') { + return null; + } + + return match (strtolower($encryption)) { + 'ssl', 'smtps' => 'smtps', + 'tls', 'smtp' => 'smtp', + default => 'smtp', + }; + } +} diff --git a/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php new file mode 100644 index 000000000..4d6dec839 --- /dev/null +++ b/modules/SystemSetting/Support/MigrateRobotsTxtFromSiteSetting.php @@ -0,0 +1,79 @@ +legacyRobotsKeys(); + + $row = DB::table($legacyTable) + ->where('group_key', $group) + ->where('key', $key) + ->where('locale', $locale) + ->whereNull('deleted_at') + ->first(); + + if ($row === null || trim((string) ($row->value ?? '')) === '') { + return false; + } + + $general = General::single(); + $seo = is_array($general->seo) ? $general->seo : []; + $seo['robots_txt'] = rtrim((string) $row->value, "\r\n"); + + $this->generalRepository->update($general->id, [ + 'seo' => $seo, + ]); + + $this->systemSettings->forgetCache(); + + return true; + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + protected function legacyRobotsKeys(): array + { + $cfg = (array) modularousConfig('cms_seo.robots.legacy_site_setting', []); + + return [ + (string) ($cfg['group_key'] ?? 'seo'), + (string) ($cfg['key'] ?? 'global_robots_txt'), + (string) ($cfg['locale'] ?? '*'), + ]; + } +} diff --git a/modules/SystemSetting/Support/SystemSettingsInputMerger.php b/modules/SystemSetting/Support/SystemSettingsInputMerger.php new file mode 100644 index 000000000..abe3c145f --- /dev/null +++ b/modules/SystemSetting/Support/SystemSettingsInputMerger.php @@ -0,0 +1,95 @@ +|string> $defaultInputs + * @return array> + */ + public function merge(array $defaultInputs): array + { + $config = (array) modularousConfig('system_settings', []); + $inputs = $this->expandAliasesInList($defaultInputs); + $inputs = $this->applyOverrides($inputs, (array) ($config['input_overrides'] ?? [])); + $inputs = array_merge($inputs, $this->expandAliasesInList((array) ($config['extra_inputs'] ?? []))); + + $hidden = array_values(array_filter((array) ($config['hidden_inputs'] ?? []))); + + if ($hidden === []) { + return $inputs; + } + + return array_values(array_filter( + $inputs, + fn (array $input) => ! in_array((string) ($input['name'] ?? ''), $hidden, true) + )); + } + + /** + * @param array> $inputs + * @param array> $overrides + * @return array> + */ + protected function applyOverrides(array $inputs, array $overrides): array + { + if ($overrides === []) { + return $inputs; + } + + return array_map(function (array $input) use ($overrides): array { + $name = (string) ($input['name'] ?? ''); + + if ($name === '' || ! isset($overrides[$name])) { + return $input; + } + + return array_replace_recursive($input, $overrides[$name]); + }, $inputs); + } + + /** + * @param array|string> $inputs + * @return array> + */ + protected function expandAliasesInList(array $inputs): array + { + $resolved = []; + + foreach ($inputs as $input) { + if (is_string($input)) { + $definition = modularousConfig('input_types.' . $input); + if (is_array($definition)) { + $resolved[] = $definition; + + continue; + } + + $hydrated = function_exists('hydrate_input_type') + ? hydrate_input_type(['type' => $input]) + : null; + if (is_array($hydrated)) { + $resolved[] = $hydrated; + } + + continue; + } + + if (is_array($input)) { + if (isset($input['type']) && is_string($input['type']) && str_starts_with($input['type'], '@')) { + $input = function_exists('hydrate_input_type') ? hydrate_input_type($input) : $input; + } + + $resolved[] = $input; + } + } + + return $resolved; + } +} diff --git a/modules/SystemSetting/Transformers/GeneralResource.php b/modules/SystemSetting/Transformers/GeneralResource.php index e451b44b6..9569b6950 100644 --- a/modules/SystemSetting/Transformers/GeneralResource.php +++ b/modules/SystemSetting/Transformers/GeneralResource.php @@ -1,16 +1,33 @@ */ public function toArray($request): array { - return parent::toArray($request); + $data = parent::toArray($request); + + if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) { + $password = $data['smtp']['password']; + $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null; + } + + foreach ((array) modularousConfig('system_settings.sensitive_keys', ['smtp.password']) as $dotKey) { + if (Arr::has($data, $dotKey)) { + $value = Arr::get($data, $dotKey); + Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null); + } + } + + return $data; } } diff --git a/modules/SystemSetting/module.json b/modules/SystemSetting/module.json index 2a14a347b..9fa86d6fa 100644 --- a/modules/SystemSetting/module.json +++ b/modules/SystemSetting/module.json @@ -5,7 +5,7 @@ "keywords": [], "priority": 0, "providers": [ - + "Modules\\SystemSetting\\Providers\\SystemSettingServiceProvider" ], "files": [] } From 42a09b78c6a436ccb3169442cd42e1175ee3b027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:36:06 +0300 Subject: [PATCH 06/17] feat(CmsSettings): implement new site settings management with structured sections for site, social, contact, and SEO; deprecate legacy SEO settings handling --- modules/Cms/Config/config.php | 60 +++-- modules/Cms/Entities/SiteSetting.php | 42 +++- .../Controllers/SiteSeoSettingsController.php | 21 +- .../Controllers/SiteSeoToolController.php | 29 ++- .../Cms/Http/Requests/SiteSettingRequest.php | 8 +- modules/Cms/Providers/CmsServiceProvider.php | 14 ++ .../Repositories/SiteSettingRepository.php | 49 ++-- .../views/layout_builder/shell.blade.php | 37 ++- modules/Cms/Routes/web.php | 3 +- modules/Cms/Services/CmsSettingsService.php | 47 ++++ .../Services/CmsSiteSeoSettingsService.php | 45 ++-- modules/Cms/Services/SiteSettingsService.php | 219 ++++++++++++++++++ .../DuplicateSystemSettingsToCmsSettings.php | 72 ++++++ .../Cms/Transformers/SiteSettingResource.php | 33 +++ 14 files changed, 548 insertions(+), 131 deletions(-) create mode 100644 modules/Cms/Services/CmsSettingsService.php create mode 100644 modules/Cms/Services/SiteSettingsService.php create mode 100644 modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php create mode 100644 modules/Cms/Transformers/SiteSettingResource.php diff --git a/modules/Cms/Config/config.php b/modules/Cms/Config/config.php index 7473e9f05..2f18c29e0 100644 --- a/modules/Cms/Config/config.php +++ b/modules/Cms/Config/config.php @@ -442,22 +442,52 @@ 'url' => 'site-settings', 'route_name' => 'site_setting', 'icon' => 'mdi-cog-sync-outline', - 'title_column_key' => 'key', - 'table_options' => [ - 'createOnModal' => true, - 'editOnModal' => true, - ], - 'headers' => [ - ['title' => 'Group', 'key' => 'group_key', 'searchable' => true], - ['title' => 'Key', 'key' => 'key', 'searchable' => true], - ['title' => 'Locale', 'key' => 'locale', 'searchable' => true], - ['title' => 'Actions', 'key' => 'actions', 'sortable' => false], - ], + 'title_column_key' => 'site.name', 'inputs' => [ - ['name' => 'group_key', 'label' => 'Group', 'type' => 'text', 'rules' => 'required'], - ['name' => 'key', 'label' => 'Key', 'type' => 'text', 'rules' => 'required'], - ['name' => 'locale', 'label' => 'Locale', 'type' => 'locale', 'rules' => 'required'], - ['name' => 'value', 'label' => 'Value', 'type' => 'textarea'], + [ + 'type' => '@collapsible_group', + 'name' => 'site', + 'typeIntTitle' => 'Site', + 'schema' => [ + ['name' => 'name', 'type' => 'text', 'label' => 'Site name', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'tagline', 'type' => 'text', 'label' => 'Tagline', 'translated' => true, 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'logo', 'type' => 'image', 'label' => 'Logo', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'favicon', 'type' => 'image', 'label' => 'Favicon', 'translated' => true, 'col' => ['cols' => 12, 'lg' => 6], 'imageCol' => ['cols' => 12, 'lg' => 12, 'md' => 12]], + ['name' => 'email', 'type' => 'text', 'label' => 'Email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'phone', 'type' => 'input-phone', 'label' => 'Phone', 'rules' => 'nullable|string|max:64', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'address', 'type' => 'textarea', 'label' => 'Address', 'translated' => true, 'rules' => 'nullable|string'], + ], + ], + [ + 'type' => '@collapsible_wrap', + 'typeIntTitle' => 'Social Links', + 'schema' => [ + ['type' => '@system_social_links'], + ], + ], + [ + 'type' => '@collapsible_group', + 'typeIntTitle' => 'Contact', + 'name' => 'contact', + 'schema' => [ + ['name' => 'support_email', 'type' => 'text', 'label' => 'Support email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'sales_email', 'type' => 'text', 'label' => 'Sales email', 'rules' => 'nullable|email|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'cc', 'type' => 'text', 'label' => 'Contact CC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ['name' => 'bcc', 'type' => 'text', 'label' => 'Contact BCC', 'rules' => 'nullable|string|max:255', 'col' => ['cols' => 12, 'lg' => 6]], + ], + ], + [ + 'type' => '@collapsible_group', + 'name' => 'seo', + 'typeIntTitle' => 'SEO', + 'schema' => [ + ['name' => 'robots_txt', 'type' => 'textarea', 'label' => 'Global robots.txt', 'rules' => 'nullable|string'], + ['name' => 'default_meta_title', 'type' => 'text', 'label' => 'Default meta title', 'translated' => true, 'rules' => 'nullable|string|max:255'], + ['name' => 'default_meta_description', 'type' => 'textarea', 'label' => 'Default meta description', 'translated' => true, 'rules' => 'nullable|string'], + ['name' => 'og_image', 'type' => 'image', 'label' => 'Default OG image', 'rules' => 'nullable'], + ['name' => 'json_schema', 'type' => 'textarea', 'label' => 'Global JSON-LD (Schema.org)', 'rules' => 'nullable|string', 'hint' => 'Raw JSON-LD object/array for the site homepage defaults'], + ], + ], ], ], 'redirect' => [ diff --git a/modules/Cms/Entities/SiteSetting.php b/modules/Cms/Entities/SiteSetting.php index 029dbcafa..34d6646cc 100644 --- a/modules/Cms/Entities/SiteSetting.php +++ b/modules/Cms/Entities/SiteSetting.php @@ -1,25 +1,51 @@ + */ protected $fillable = [ - 'group_key', - 'key', - 'locale', - 'value', - 'is_active', + 'published', + 'site', + 'social', + 'contact', + 'seo', ]; + /** + * @var array + */ protected $casts = [ - 'is_active' => 'boolean', + 'site' => 'array', + 'social' => 'array', + 'contact' => 'array', + 'seo' => 'array', + 'published' => 'boolean', ]; - public function getTable(): string + /** + * @return list + */ + public static function settingsSections(): array { - return modularousConfig('tables.cms_site_settings', 'um_cms_site_settings'); + return ['site', 'social', 'contact', 'seo']; } } diff --git a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php index 706e8812d..d67fd3388 100644 --- a/modules/Cms/Http/Controllers/SiteSeoSettingsController.php +++ b/modules/Cms/Http/Controllers/SiteSeoSettingsController.php @@ -5,28 +5,17 @@ use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller; use Modules\Cms\Http\Requests\SiteSeoSettingsRequest; -use Modules\Cms\Services\CmsSiteSeoSettingsService; /** - * Persists site-wide SEO fields (session web route for the panel). + * @deprecated Global robots.txt is edited under System Settings → General. */ class SiteSeoSettingsController extends Controller { - public function update(SiteSeoSettingsRequest $request, CmsSiteSeoSettingsService $service): JsonResponse + public function update(SiteSeoSettingsRequest $request): JsonResponse { - if (! modularousConfig('cms_seo.robots.use_site_settings', true)) { - return response()->json([ - 'ok' => false, - 'message' => __('Database-backed site SEO is disabled in configuration.'), - ], 422); - } - - $data = $request->validated(); - $service->saveGlobalRobotsTxt($data['global_robots_txt'] ?? null); - return response()->json([ - 'ok' => true, - 'message' => __('Site SEO settings saved.'), - ]); + 'ok' => false, + 'message' => __('Global robots.txt is managed under System Settings → General.'), + ], 410); } } diff --git a/modules/Cms/Http/Controllers/SiteSeoToolController.php b/modules/Cms/Http/Controllers/SiteSeoToolController.php index fd3bb3f79..639a26997 100644 --- a/modules/Cms/Http/Controllers/SiteSeoToolController.php +++ b/modules/Cms/Http/Controllers/SiteSeoToolController.php @@ -7,12 +7,11 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Response; -use Modules\Cms\Services\CmsSiteSeoSettingsService; use Unusualify\Modularous\Facades\Modularous; use Unusualify\Modularous\Http\Controllers\BaseController; /** - * Inertia shell for CMS site-wide SEO (global robots.txt body stored in site_settings). + * Inertia shell for CMS site-wide SEO tools (page-level SEO; robots.txt lives in System Settings). */ class SiteSeoToolController extends BaseController { @@ -25,7 +24,7 @@ public function __construct(Application $app, Request $request) parent::__construct($app, $request); } - public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response + public function __invoke(): Response { $pageTitle = __('Site SEO') . ' - ' . Modularous::pageTitle(); $headerTitle = __('Site SEO'); @@ -40,20 +39,32 @@ public function __invoke(CmsSiteSeoSettingsService $siteSeo): Response $this->shareInertiaStoreVariables(); - $prefix = $this->module->panelRouteNamePrefix() . '.'; + $systemSettingsUrl = $this->resolveSystemSettingsUrl(); return Inertia::render('SiteSeo', [ - 'siteSeoEndpoints' => [ - 'save' => route($prefix . 'siteSeo.save'), - ], - 'globalRobotsTxt' => $siteSeo->globalRobotsTxtForEditor(), - 'useSiteSettings' => (bool) modularousConfig('cms_seo.robots.use_site_settings', true), + 'systemSettingsUrl' => $systemSettingsUrl, 'endpoints' => new \stdClass, 'mainConfiguration' => $this->getInertiaMainConfiguration($data), 'headLayoutData' => $this->getHeadLayoutData($data), ]); } + protected function resolveSystemSettingsUrl(): ?string + { + $candidates = [ + systemRouteNamePrefix() . '.systemsetting.general.index', + modularousConfig('admin_route_name_prefix', 'admin') . '.system.systemsetting.general.index', + ]; + + foreach ($candidates as $routeName) { + if (Route::has($routeName)) { + return route($routeName); + } + } + + return null; + } + /** * @return array */ diff --git a/modules/Cms/Http/Requests/SiteSettingRequest.php b/modules/Cms/Http/Requests/SiteSettingRequest.php index 1500809da..4d63afd7e 100644 --- a/modules/Cms/Http/Requests/SiteSettingRequest.php +++ b/modules/Cms/Http/Requests/SiteSettingRequest.php @@ -8,13 +8,7 @@ class SiteSettingRequest extends Request { public function rulesForAll() { - return [ - 'group_key' => 'required|string|max:100', - 'key' => 'required|string|max:100', - 'locale' => 'required|string|max:12', - 'value' => 'nullable|string', - 'is_active' => 'nullable|boolean', - ]; + return []; } public function rulesForCreate() diff --git a/modules/Cms/Providers/CmsServiceProvider.php b/modules/Cms/Providers/CmsServiceProvider.php index 62405903c..6da2fbe53 100644 --- a/modules/Cms/Providers/CmsServiceProvider.php +++ b/modules/Cms/Providers/CmsServiceProvider.php @@ -46,6 +46,7 @@ use Modules\Cms\Services\CmsParentSegmentResolver; use Modules\Cms\Services\CmsPromotionService; use Modules\Cms\Services\CmsPublicModelResolver; +use Modules\Cms\Services\CmsSettingsService; use Modules\Cms\Services\CmsSignedPreviewTargetResolver; use Modules\Cms\Services\CmsSignedPreviewUrlGenerator; use Modules\Cms\Services\CmsSitemapBuildService; @@ -58,6 +59,7 @@ use Modules\Cms\Services\DefaultCmsPromotionScopeApplier; use Modules\Cms\Services\NullLeadDelivery; use Modules\Cms\Services\RedirectValidationService; +use Modules\Cms\Services\SiteSettingsService; use Modules\Cms\Services\Stylesheet\FrameworkArtifactResolver; use Modules\Cms\Services\Stylesheet\FrameworkScriptResolver; use Modules\Cms\Services\Stylesheet\RootVariablesEmitter; @@ -66,6 +68,7 @@ use Modules\Cms\Services\Stylesheet\UtilityCssGenerator; use Modules\Cms\Support\CmsPublicUrlRegistryAboutReporter; use Modules\Cms\Support\CmsPublicUrlRegistryCacheManager; +use Modules\Cms\Support\DuplicateSystemSettingsToCmsSettings; use Unusualify\Modularous\Facades\ModularousCache; use Unusualify\Modularous\Services\Security\SecurityService; use Unusualify\Modularous\Services\SlugInputValidationService; @@ -110,6 +113,11 @@ public function register(): void $this->app->singleton(CmsSitemapCacheService::class); $this->app->singleton(CmsAdminWarnings::class); $this->app->singleton(CmsSiteSeoSettingsService::class); + $this->app->singleton(CmsSettingsService::class); + $this->app->alias(CmsSettingsService::class, 'cms.settings'); + $this->app->singleton(SiteSettingsService::class); + $this->app->alias(SiteSettingsService::class, 'site.settings'); + $this->app->singleton(DuplicateSystemSettingsToCmsSettings::class); $this->app->singleton(SlugInputValidationService::class, CmsSlugInputValidationService::class); $this->app->singleton(CmsSignedPreviewUrlGenerator::class); $this->app->singleton(CmsSignedPreviewTargetResolver::class); @@ -188,6 +196,12 @@ public function boot(): void $this->registerCmsSignedPreviewRoutes(); $this->registerCmsPublicStylesheetRoutes(); $this->registerCmsPublishSchedule(); + + try { + $this->app->make(DuplicateSystemSettingsToCmsSettings::class)->duplicateIfNeeded(); + } catch (\Throwable) { + // Seed is best-effort when singletons / DB are unavailable. + } } /** diff --git a/modules/Cms/Repositories/SiteSettingRepository.php b/modules/Cms/Repositories/SiteSettingRepository.php index 4c8ee11e2..e56faa0d6 100644 --- a/modules/Cms/Repositories/SiteSettingRepository.php +++ b/modules/Cms/Repositories/SiteSettingRepository.php @@ -1,58 +1,37 @@ model = $model; } /** - * Single site-setting row for the given composite key (includes soft-deleted for restore semantics). + * @param \Illuminate\Database\Eloquent\Model $object + * @param array $fields */ - public function findScoped(string $groupKey, string $key, string $locale): ?SiteSetting + public function afterSave($object, $fields): void { - return $this->model->newQuery() - ->withTrashed() - ->where('group_key', $groupKey) - ->where('key', $key) - ->where('locale', $locale) - ->first(); - } + parent::afterSave($object, $fields); - /** - * Persist a value or remove the row when {@code $value} is null or whitespace-only (revert to env/config fallback). - */ - public function putScoped(string $groupKey, string $key, string $locale, ?string $value): void - { - if ($value === null || trim($value) === '') { - $existing = $this->findScoped($groupKey, $key, $locale); - if ($existing !== null) { - $existing->forceDelete(); - } - - return; + if (app()->bound('cms.settings')) { + app('cms.settings')->forgetCache(); } - $model = $this->findScoped($groupKey, $key, $locale) ?? $this->model->newInstance([ - 'group_key' => $groupKey, - 'key' => $key, - 'locale' => $locale, - ]); - - if ($model->trashed()) { - $model->restore(); + if (app()->bound('site.settings')) { + app('site.settings')->forgetCache(); } - - $model->fill([ - 'value' => $value, - 'is_active' => true, - ]); - $model->save(); } } diff --git a/modules/Cms/Resources/views/layout_builder/shell.blade.php b/modules/Cms/Resources/views/layout_builder/shell.blade.php index 381e29e41..089f63cb0 100644 --- a/modules/Cms/Resources/views/layout_builder/shell.blade.php +++ b/modules/Cms/Resources/views/layout_builder/shell.blade.php @@ -5,25 +5,44 @@ - @if(! empty($seoDescription)) - - @endif - @if(! empty($canonicalUrl)) - - @endif - @if(! empty($robotsMeta)) - - @endif + + @php + $resolvedSiteName = SiteSettings::get('site.name', config('app.name')); + $resolvedMetaTitle = $seoTitle ?? $siteTitle ?? SiteSettings::get('seo.default_meta_title', $resolvedSiteName); + $resolvedMetaDescription = $seoDescription ?? SiteSettings::get('seo.default_meta_description', ''); + $resolvedFavicon = SiteSettings::value('site.favicon.original') ?: asset('favicon.ico'); + @endphp + + {{ $resolvedMetaTitle }} + + + + + + + + + + + + {{-- Optional CMP / consent (e.g. CookieFirst) must precede GTM --}} + {!! $consentHeadHtml ?? '' !!} + {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->headHtml() !!} + + + @foreach ($stylesheetHrefs ?? [] as $href) @endforeach + {!! $headHtml ?? '' !!} @if (! empty($cmsLayoutUseInjectionMarkers)) {!! $cmsLayoutMarkerHeadAppend ?? '' !!} @endif + {!! app(\Modules\SystemSetting\Support\AnalyticsScripts::class)->bodyOpenHtml() !!} {!! $bodyHtml ?? '' !!} @if (! empty($cmsLayoutUseInjectionMarkers)) {!! $cmsLayoutMarkerBeforeFooter ?? '' !!} diff --git a/modules/Cms/Routes/web.php b/modules/Cms/Routes/web.php index e03099076..da2b5063b 100644 --- a/modules/Cms/Routes/web.php +++ b/modules/Cms/Routes/web.php @@ -2,10 +2,11 @@ use Illuminate\Support\Facades\Route; use Modules\Cms\Http\Controllers\API\PromotionController; +use Modules\Cms\Http\Controllers\LayoutBuilderHtmlPreviewController; +use Modules\Cms\Http\Controllers\LayoutBuilderShellDraftPreviewController; use Modules\Cms\Http\Controllers\PromotionToolController; use Modules\Cms\Http\Controllers\SignedPublicPreviewMintController; use Modules\Cms\Http\Controllers\SitemapController; -use Modules\Cms\Http\Controllers\SiteSeoSettingsController; use Modules\Cms\Http\Controllers\SiteSeoToolController; use Unusualify\Modularous\Facades\ModularousRoutes; diff --git a/modules/Cms/Services/CmsSettingsService.php b/modules/Cms/Services/CmsSettingsService.php new file mode 100644 index 000000000..628b0d3b4 --- /dev/null +++ b/modules/Cms/Services/CmsSettingsService.php @@ -0,0 +1,47 @@ +siteSettingRepository; + } + + protected function settingsSections(): array + { + return SiteSetting::settingsSections(); + } +} diff --git a/modules/Cms/Services/CmsSiteSeoSettingsService.php b/modules/Cms/Services/CmsSiteSeoSettingsService.php index 7d1475ab7..d53226230 100644 --- a/modules/Cms/Services/CmsSiteSeoSettingsService.php +++ b/modules/Cms/Services/CmsSiteSeoSettingsService.php @@ -2,23 +2,15 @@ namespace Modules\Cms\Services; -use Modules\Cms\Entities\SiteSetting; -use Modules\Cms\Http\Controllers\Front\RobotsTxtController; -use Modules\Cms\Repositories\SiteSettingRepository; use Modules\Cms\Support\CmsPublicSeo; +use Unusualify\Modularous\Facades\SiteSettings; /** - * Persists site-wide SEO options in {@see SiteSetting} (key-value rows). - * - * Global robots.txt body is stored under the configured group/key/locale and read by - * {@see RobotsTxtController} when `cms_seo.robots.use_site_settings` is true. + * Site-wide SEO helpers. Global robots.txt is resolved via {@see SiteSettings} + * (CmsSettings on frontend with SystemSettings fallback). */ class CmsSiteSeoSettingsService { - public function __construct( - protected SiteSettingRepository $siteSettings, - ) {} - /** * Body served at GET /robots.txt (normalized trailing newline). */ @@ -31,7 +23,7 @@ public function resolvedRobotsTxtBody(): string $default = "User-agent: *\nAllow: /"; $raw = null; - if (modularousConfig('cms_seo.robots.use_site_settings', true)) { + if (modularousConfig('cms_seo.robots.use_system_settings', true)) { $persisted = $this->persistedGlobalRobotsTxt(); if ($persisted !== null) { $raw = trim($persisted); @@ -53,14 +45,17 @@ public function resolvedRobotsTxtBody(): string } /** - * Raw value from DB, or null when unset (use env/config in UI and public fallback). + * Raw value from SiteSettings, or null when unset (use env/config in UI and public fallback). */ public function persistedGlobalRobotsTxt(): ?string { - [$g, $k, $locale] = $this->robotsSettingKeys(); - $row = $this->siteSettings->findScoped($g, $k, $locale); + if (! SiteSettings::forFrontend()->has('seo.robots_txt')) { + return null; + } - return $row !== null ? (string) $row->value : null; + $value = SiteSettings::forFrontend()->get('seo.robots_txt'); + + return $value !== null ? (string) $value : null; } /** @@ -88,21 +83,9 @@ public function globalRobotsTxtForEditor(): string public function saveGlobalRobotsTxt(?string $value): void { - [$g, $k, $locale] = $this->robotsSettingKeys(); - $this->siteSettings->putScoped($g, $k, $locale, $value); - } - - /** - * @return array{0: string, 1: string, 2: string} - */ - protected function robotsSettingKeys(): array - { - $cfg = (array) modularousConfig('cms_seo.robots.site_setting', []); + $normalized = $value === null || trim($value) === '' ? null : rtrim($value, "\r\n"); - return [ - (string) ($cfg['group_key'] ?? 'seo'), - (string) ($cfg['key'] ?? 'global_robots_txt'), - (string) ($cfg['locale'] ?? '*'), - ]; + // Panel SEO tool writes the system-wide default; CMS Site Settings can override per frontend. + SiteSettings::forBackend()->set('seo.robots_txt', $normalized); } } diff --git a/modules/Cms/Services/SiteSettingsService.php b/modules/Cms/Services/SiteSettingsService.php new file mode 100644 index 000000000..7340c83e5 --- /dev/null +++ b/modules/Cms/Services/SiteSettingsService.php @@ -0,0 +1,219 @@ +forcedContext = 'frontend'; + + return $clone; + } + + /** + * Force SystemSettings only (backend semantics). + */ + public function forBackend(): static + { + $clone = clone $this; + $clone->forcedContext = 'backend'; + + return $clone; + } + + /** + * Alias of {@see forFrontend()}. + */ + public function forCms(): static + { + return $this->forFrontend(); + } + + /** + * Alias of {@see forBackend()}. + */ + public function forSystem(): static + { + return $this->forBackend(); + } + + public function usesCmsLayer(): bool + { + if ($this->forcedContext === 'frontend') { + return true; + } + + if ($this->forcedContext === 'backend') { + return false; + } + + if (app()->runningInConsole()) { + return false; + } + + try { + return ! Modularous::isPanelUrl(); + } catch (\Throwable) { + return true; + } + } + + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + if ($this->usesCmsLayer()) { + if ($this->cmsSettings->filled($key, $locale)) { + return $this->cmsSettings->get($key, null, $locale); + } + + return $this->systemSettings->get($key, $default, $locale); + } + + return $this->systemSettings->get($key, $default, $locale); + } + + public function value(string $key, mixed $default = null, ?string $locale = null): mixed + { + return $this->get($key, $default, $locale); + } + + public function first(string $key, mixed $default = null, ?string $locale = null): mixed + { + if ($this->usesCmsLayer()) { + if ($this->cmsSettings->filled($key, $locale)) { + return $this->cmsSettings->first($key, null, $locale); + } + + return $this->systemSettings->first($key, $default, $locale); + } + + return $this->systemSettings->first($key, $default, $locale); + } + + /** + * @return array + */ + public function all(?string $locale = null): array + { + if (! $this->usesCmsLayer()) { + return $this->systemSettings->all($locale); + } + + return array_replace_recursive( + $this->systemSettings->all($locale), + $this->filterEmptyBranches($this->cmsSettings->all($locale)), + ); + } + + public function has(string $key): bool + { + if ($this->usesCmsLayer()) { + return $this->cmsSettings->has($key) || $this->systemSettings->has($key); + } + + return $this->systemSettings->has($key); + } + + public function filled(string $key, ?string $locale = null): bool + { + if ($this->usesCmsLayer()) { + return $this->cmsSettings->filled($key, $locale) || $this->systemSettings->filled($key, $locale); + } + + return $this->systemSettings->filled($key, $locale); + } + + public function set(string $key, mixed $value): void + { + if ($this->usesCmsLayer()) { + $this->cmsSettings->set($key, $value); + + return; + } + + $this->systemSettings->set($key, $value); + } + + public function forgetCache(): void + { + $this->cmsSettings->forgetCache(); + $this->systemSettings->forgetCache(); + } + + /** + * @return array + */ + public function snapshot(): array + { + if ($this->usesCmsLayer()) { + return array_replace_recursive( + $this->systemSettings->snapshot(), + $this->filterEmptyBranches($this->cmsSettings->snapshot()), + ); + } + + return $this->systemSettings->snapshot(); + } + + public function cms(): CmsSettingsService + { + return $this->cmsSettings; + } + + public function system(): SystemSettingsService + { + return $this->systemSettings; + } + + /** + * Drop empty leaves so SystemSettings values remain visible under array_replace_recursive. + * + * @param array $tree + * @return array + */ + protected function filterEmptyBranches(array $tree): array + { + $filtered = []; + + foreach ($tree as $key => $value) { + if (is_array($value)) { + $nested = $this->filterEmptyBranches($value); + if ($nested !== []) { + $filtered[$key] = $nested; + } + + continue; + } + + if ($value !== null && $value !== '') { + $filtered[$key] = $value; + } + } + + return $filtered; + } +} diff --git a/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php new file mode 100644 index 000000000..499c4931d --- /dev/null +++ b/modules/Cms/Support/DuplicateSystemSettingsToCmsSettings.php @@ -0,0 +1,72 @@ +hasMeaningfulContent($siteSetting)) { + return false; + } + + if (! $this->hasMeaningfulContent($general)) { + return false; + } + + $fields = ['published' => (bool) ($general->published ?? true)]; + + foreach (SiteSetting::settingsSections() as $section) { + $value = $general->getAttribute($section); + $fields[$section] = is_array($value) ? $value : []; + } + + $this->siteSettingRepository->update($siteSetting->id, $fields); + $this->cmsSettings->forgetCache(); + + return true; + } + + protected function hasMeaningfulContent(object $model): bool + { + foreach (SiteSetting::settingsSections() as $section) { + $value = $model->getAttribute($section); + + if (is_array($value) && $value !== []) { + return true; + } + + if ($value !== null && $value !== '' && $value !== []) { + return true; + } + } + + return false; + } +} diff --git a/modules/Cms/Transformers/SiteSettingResource.php b/modules/Cms/Transformers/SiteSettingResource.php new file mode 100644 index 000000000..8caad966f --- /dev/null +++ b/modules/Cms/Transformers/SiteSettingResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray($request): array + { + $data = parent::toArray($request); + + if (isset($data['smtp']) && is_array($data['smtp']) && array_key_exists('password', $data['smtp'])) { + $password = $data['smtp']['password']; + $data['smtp']['password'] = ($password !== null && $password !== '') ? '********' : null; + } + + foreach ((array) modularousConfig('cms_settings.sensitive_keys', modularousConfig('system_settings.sensitive_keys', ['smtp.password'])) as $dotKey) { + if (Arr::has($data, $dotKey)) { + $value = Arr::get($data, $dotKey); + Arr::set($data, $dotKey, ($value !== null && $value !== '') ? '********' : null); + } + } + + return $data; + } +} From 32d3dd53485f0dc9a3f8cf67790bd6e95eff1bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:36:32 +0300 Subject: [PATCH 07/17] feat(Merges): add new collapsible wrap and group input types for enhanced UI structure --- config/merges/input_types.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/config/merges/input_types.php b/config/merges/input_types.php index 14c80e655..23575bb6d 100644 --- a/config/merges/input_types.php +++ b/config/merges/input_types.php @@ -3,6 +3,24 @@ use Camroncade\Timezone\Timezone; return [ + '@collapsible_wrap' => [ + 'type' => 'wrap', + 'typeInt' => 'ue-collapsible', + 'typeIntModelValue' => false, + 'typeIntTitle' => 'Wrap', + 'noLabel' => true, + 'bordered' => true, + 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12], + ], + '@collapsible_group' => [ + 'type' => 'group', + 'typeInt' => 'ue-collapsible', + 'typeIntModelValue' => false, + 'typeIntTitle' => 'Group', + 'noLabel' => true, + 'bordered' => true, + 'col' => ['cols' => 12,'sm' => 12,'md' => 12,'lg' => 12,'xl' => 12], + ], '_language' => [ 'type' => 'select', 'name' => 'language', From a6ab42c0efb369b5a17c18e782aae5ca79a11a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:36:49 +0300 Subject: [PATCH 08/17] feat(SystemSettings): add new configuration file for system settings with support for analytics, SMTP overrides, and social links management --- config/merges/system_settings.php | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 config/merges/system_settings.php diff --git a/config/merges/system_settings.php b/config/merges/system_settings.php new file mode 100644 index 000000000..7ad9b005c --- /dev/null +++ b/config/merges/system_settings.php @@ -0,0 +1,45 @@ + 'SystemSetting', + 'system_prefix' => true, + 'group' => 'system', + 'headline' => 'System Settings', + 'cache_ttl' => (int) env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600), + /** + * Fallback when SystemSettings `analytics.enabled` is unset in the DB. + * Staging should leave DB off (or unset) so this stays false. + * Prod: set analytics.enabled=true in System Settings (or MODULAROUS_ANALYTICS_ENABLED=true as seed default). + */ + 'analytics_enabled_default' => (bool) env('MODULAROUS_ANALYTICS_ENABLED', false), + /** + * When true, smtp.* from System Settings overrides config/mail.php. + * Prefer .env MAIL_* unless you explicitly need CMS-managed SMTP. + */ + 'mail_override_enabled' => (bool) env('MODULAROUS_SYSTEM_SETTINGS_MAIL_OVERRIDE', false), + 'sensitive_keys' => [ + 'smtp.password', + ], + 'extra_inputs' => [], + 'input_overrides' => [], + 'hidden_inputs' => [], + 'input_aliases' => [ + '@system_social_links' => [ + 'type' => 'json-repeater', + 'name' => 'social', + 'label' => 'Social Links', + 'default' => [], + 'draggable' => true, + 'orderKey' => 'position', + 'noHeaders' => true, + 'collapsible' => true, + 'collapsibleTitleField' => 'platform', + 'collapsibleDefaultOpen' => false, + 'schema' => [ + ['name' => 'platform', 'type' => 'text', 'label' => 'Platform', 'rules' => 'required'], + ['name' => 'url', 'type' => 'text', 'label' => 'URL', 'rules' => 'required'], + ['name' => 'icon', 'type' => 'image', 'label' => 'Icon', 'rules' => 'nullable'], + ], + ] + ], +]; From 85ab33c7067398d69fce0a5cdd79b485e77da2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:00 +0300 Subject: [PATCH 09/17] feat(RobotsTxt): update robots.txt configuration to use system settings, deprecate legacy site settings, and introduce new cms_settings.php for cache and sensitive keys management --- config/merges/cms_seo.php | 13 +++++++++---- config/merges/cms_settings.php | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 config/merges/cms_settings.php diff --git a/config/merges/cms_seo.php b/config/merges/cms_seo.php index 231f1968a..bf8d639d6 100644 --- a/config/merges/cms_seo.php +++ b/config/merges/cms_seo.php @@ -7,6 +7,7 @@ use Modules\Cms\Services\CmsAdminWarnings; use Modules\Cms\Services\CmsSiteSeoSettingsService; use Modules\Cms\Support\CmsPublicSeo; +use Modules\SystemSetting\Support\MigrateRobotsTxtFromSiteSetting; use Unusualify\Modularous\Entities\Traits\Core\HasScopes; return [ @@ -32,19 +33,23 @@ * Global robots.txt (served at GET /robots.txt when route enabled). * * @see RobotsTxtController + * @see CmsSiteSeoSettingsService */ 'robots' => [ 'route_enabled' => env('MODULAROUS_CMS_ROBOTS_TXT_ROUTE_ENABLED', true), 'global_robots_txt' => env('MODULAROUS_CMS_SEO_GLOBAL_ROBOTS_TXT', ''), /** - * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} (um_cms_site_settings). + * When true, GET /robots.txt prefers {@see CmsSiteSeoSettingsService} + * ({@see \Unusualify\Modularous\Facades\SiteSettings} → CmsSettings with SystemSettings fallback). * When false, only env/config {@code global_robots_txt} is used (legacy / headless deploys). */ - 'use_site_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true), + 'use_system_settings' => env('MODULAROUS_CMS_SEO_ROBOTS_USE_SYSTEM_SETTINGS', env('MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS', true)), /** - * Composite key for the global robots.txt body row (must match unique index on site_settings). + * Legacy KV row keys used only by {@see MigrateRobotsTxtFromSiteSetting}. + * + * @deprecated Robots.txt is stored on SystemSetting General / Cms SiteSetting (IsSingular). */ - 'site_setting' => [ + 'legacy_site_setting' => [ 'group_key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_GROUP', 'seo'), 'key' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_KEY', 'global_robots_txt'), 'locale' => env('MODULAROUS_CMS_SEO_ROBOTS_SITE_LOCALE', '*'), diff --git a/config/merges/cms_settings.php b/config/merges/cms_settings.php new file mode 100644 index 000000000..d019a3b96 --- /dev/null +++ b/config/merges/cms_settings.php @@ -0,0 +1,8 @@ + (int) env('MODULAROUS_CMS_SETTINGS_CACHE_TTL', env('MODULAROUS_SYSTEM_SETTINGS_CACHE_TTL', 3600)), + 'sensitive_keys' => [ + 'smtp.password', + ], +]; From 32351d3541048b90167bb9aea5dacdca54e7daa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:15 +0300 Subject: [PATCH 10/17] feat(Settings): introduce new facade classes for CmsSettings, SiteSettings, and SystemSettings; enhance composer.json with new aliases for improved settings management --- composer.json | 5 +- src/Facades/CmsSettings.php | 29 ++ src/Facades/SiteSettings.php | 40 ++ src/Facades/SystemSettings.php | 29 ++ .../AbstractSingularSettingsService.php | 415 ++++++++++++++++++ 5 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 src/Facades/CmsSettings.php create mode 100644 src/Facades/SiteSettings.php create mode 100644 src/Facades/SystemSettings.php create mode 100644 src/Services/Settings/AbstractSingularSettingsService.php diff --git a/composer.json b/composer.json index ea7251820..c4d856d54 100755 --- a/composer.json +++ b/composer.json @@ -87,7 +87,10 @@ "Unusualify\\Modularous\\Providers\\ModularousProvider" ], "aliases": { - "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite" + "ModularousVite": "Unusualify\\Modularous\\Facades\\ModularousVite", + "SystemSettings": "Unusualify\\Modularous\\Facades\\SystemSettings", + "CmsSettings": "Unusualify\\Modularous\\Facades\\CmsSettings", + "SiteSettings": "Unusualify\\Modularous\\Facades\\SiteSettings" } }, "branch-alias": { diff --git a/src/Facades/CmsSettings.php b/src/Facades/CmsSettings.php new file mode 100644 index 000000000..0f8f59b80 --- /dev/null +++ b/src/Facades/CmsSettings.php @@ -0,0 +1,29 @@ +|null */ + private ?array $requestSnapshot = null; + + abstract protected function cacheKey(): string; + + abstract protected function cacheTtl(): int; + + /** + * @return class-string + */ + abstract protected function modelClass(): string; + + abstract protected function repository(): Repository; + + /** + * @return list + */ + abstract protected function settingsSections(): array; + + /** + * Get a nested setting by dot path, resolving locale maps along the way. + * + * When the direct path is empty and the key has at least two segments, expands through + * locale (+ optional first list index) using the last segment as an arbitrary leaf: + * `site.logo.frontend` → `site.logo.{locale}.frontend` + * → `site.logo.{locale}.0.frontend` + * → `site.logo.{locale}.frontend.0` + */ + public function get(string $key, mixed $default = null, ?string $locale = null): mixed + { + $resolvedLocale = $locale ?? app()->getLocale(); + $snapshot = $this->snapshot(); + $segments = $key === '' ? [] : explode('.', $key); + + $value = $this->walkPath($snapshot, $key, $resolvedLocale); + + if (! $this->isEmptySettingValue($value) && ! $this->isExpandableListResult($value, $segments)) { + return $value; + } + + if (count($segments) >= 2) { + $leaf = (string) $segments[array_key_last($segments)]; + $parentKey = implode('.', array_slice($segments, 0, -1)); + $expanded = $this->resolveLeafViaLocalePaths($snapshot, $parentKey, $leaf, $resolvedLocale); + + if (! $this->isEmptySettingValue($expanded)) { + return $expanded; + } + } + + if (! $this->isEmptySettingValue($value)) { + return $value; + } + + return $default; + } + + /** + * Alias of {@see get()}. + */ + public function value(string $key, mixed $default = null, ?string $locale = null): mixed + { + return $this->get($key, $default, $locale); + } + + /** + * Get a setting; when the resolved value is a list, return its first element. + */ + public function first(string $key, mixed $default = null, ?string $locale = null): mixed + { + $resolved = $this->get($key, null, $locale); + + if ($resolved === null) { + return $default; + } + + if (is_array($resolved) && Arr::isList($resolved)) { + return $resolved[0] ?? $default; + } + + return $resolved; + } + + /** + * @return array + */ + public function all(?string $locale = null): array + { + $snapshot = $this->snapshot(); + $resolvedLocale = $locale ?? app()->getLocale(); + + return $this->resolveTranslatedTree($snapshot, $resolvedLocale); + } + + public function has(string $key): bool + { + return Arr::has($this->snapshot(), $key); + } + + /** + * True when the resolved value for {@see $key} is non-empty (after locale/leaf expansion). + */ + public function filled(string $key, ?string $locale = null): bool + { + return ! $this->isEmptySettingValue($this->get($key, null, $locale)); + } + + public function set(string $key, mixed $value): void + { + $modelClass = $this->modelClass(); + $model = $modelClass::single(); + $fields = $this->snapshotFromModel($model); + Arr::set($fields, $key, $value); + + $this->repository()->update($model->id, $fields); + $this->forgetCache(); + } + + public function forgetCache(): void + { + $this->requestSnapshot = null; + Cache::forget($this->cacheKey()); + ModularousCache::forget($this->cacheKey()); + } + + /** + * @return array + */ + public function snapshot(): array + { + if ($this->requestSnapshot !== null) { + return $this->requestSnapshot; + } + + $ttl = $this->cacheTtl(); + + $this->requestSnapshot = Cache::remember($this->cacheKey(), $ttl, function (): array { + return $this->buildSnapshotFromDatabase(); + }); + + return $this->requestSnapshot; + } + + /** + * @return array + */ + protected function buildSnapshotFromDatabase(): array + { + $modelClass = $this->modelClass(); + + if (! class_exists($modelClass)) { + return $this->defaultSnapshot(); + } + + try { + $model = $modelClass::single(); + } catch (\Throwable) { + return $this->defaultSnapshot(); + } + + return $this->snapshotFromModel($model); + } + + /** + * @param TModel $model + * @return array + */ + protected function snapshotFromModel(object $model): array + { + $snapshot = $this->defaultSnapshot(); + + foreach ($this->settingsSections() as $section) { + $value = $model->getAttribute($section); + if ($value !== null) { + $snapshot[$section] = is_array($value) ? $value : []; + } + } + + if (isset($snapshot['smtp']['password']) && is_string($snapshot['smtp']['password']) && $snapshot['smtp']['password'] !== '') { + $snapshot['smtp']['password'] = $this->decryptSecret($snapshot['smtp']['password']); + } + + return $snapshot; + } + + /** + * @return array + */ + protected function defaultSnapshot(): array + { + $snapshot = []; + + foreach ($this->settingsSections() as $section) { + $snapshot[$section] = []; + } + + return $snapshot; + } + + protected function decryptSecret(string $value): string + { + try { + return decrypt($value, false); + } catch (DecryptException) { + return $value; + } + } + + /** + * @param array $data + */ + protected function walkPath(array $data, string $key, string $locale): mixed + { + if ($key === '') { + return $this->resolveTranslatedValue($data, $locale); + } + + $cursor = $data; + + foreach (explode('.', $key) as $segment) { + if (is_array($cursor) && $this->looksLikeLocaleMap($cursor)) { + $cursor = $this->resolveTranslatedValue($cursor, $locale); + } + + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $this->resolveTranslatedValue($cursor, $locale); + } + + /** + * @param array $data + */ + protected function resolveLeafViaLocalePaths(array $data, string $parentKey, string $leaf, string $locale): mixed + { + $parent = $parentKey === '' + ? $data + : $this->walkPathRaw($data, $parentKey); + + if (! is_array($parent)) { + return null; + } + + $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en')); + $locales = []; + + foreach ([$locale, $fallback, '*'] as $candidate) { + if ($candidate === '' || in_array($candidate, $locales, true)) { + continue; + } + + $locales[] = $candidate; + } + + foreach ($locales as $tryLocale) { + if (! array_key_exists($tryLocale, $parent)) { + continue; + } + + $listDeferred = null; + + foreach ([ + "{$tryLocale}.{$leaf}", + "{$tryLocale}.0.{$leaf}", + "{$tryLocale}.{$leaf}.0", + ] as $relativePath) { + $candidate = data_get($parent, $relativePath); + + if ($this->isEmptySettingValue($candidate)) { + continue; + } + + if (is_array($candidate) && Arr::isList($candidate)) { + $listDeferred ??= $candidate; + + continue; + } + + return $candidate; + } + + if ($listDeferred !== null) { + return $listDeferred; + } + } + + return null; + } + + /** + * @param array $data + */ + protected function walkPathRaw(array $data, string $key): mixed + { + if ($key === '') { + return $data; + } + + $cursor = $data; + + foreach (explode('.', $key) as $segment) { + if (! is_array($cursor) || ! array_key_exists($segment, $cursor)) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $cursor; + } + + /** + * @param list $segments + */ + protected function isExpandableListResult(mixed $value, array $segments): bool + { + return count($segments) >= 2 + && is_array($value) + && Arr::isList($value); + } + + protected function isEmptySettingValue(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } + + protected function resolveTranslatedValue(mixed $value, string $locale): mixed + { + if (! is_array($value)) { + return $value; + } + + if ($this->looksLikeLocaleMap($value)) { + $fallback = (string) modularousConfig('fallback_locale', config('app.fallback_locale', 'en')); + + return $value[$locale] + ?? $value[$fallback] + ?? $value['*'] + ?? reset($value); + } + + return $value; + } + + /** + * @param array $tree + * @return array + */ + protected function resolveTranslatedTree(array $tree, string $locale): array + { + $resolved = []; + + foreach ($tree as $key => $value) { + if (is_array($value) && ! $this->looksLikeLocaleMap($value) && Arr::isAssoc($value)) { + $resolved[$key] = $this->resolveTranslatedTree($value, $locale); + + continue; + } + + $resolved[$key] = $this->resolveTranslatedValue($value, $locale); + } + + return $resolved; + } + + /** + * @param array $value + */ + protected function looksLikeLocaleMap(array $value): bool + { + if ($value === [] || Arr::isList($value)) { + return false; + } + + foreach (array_keys($value) as $key) { + if (! is_string($key) || ! $this->looksLikeLocaleKey($key)) { + return false; + } + } + + return true; + } + + protected function looksLikeLocaleKey(string $key): bool + { + if ($key === '*') { + return true; + } + + return (bool) preg_match('/^[a-z]{2}([_-][A-Za-z]{2,8})?$/', $key); + } +} From 2575f12beb59a009208977b766e4a59e3214e5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:29 +0300 Subject: [PATCH 11/17] feat(Favicons): update favicon handling to prioritize SystemSettings for dynamic favicon URLs, enhancing site customization options --- resources/views/partials/favicons.blade.php | 26 ++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/resources/views/partials/favicons.blade.php b/resources/views/partials/favicons.blade.php index f9e79ebfe..c918891e3 100644 --- a/resources/views/partials/favicons.blade.php +++ b/resources/views/partials/favicons.blade.php @@ -4,14 +4,24 @@ $faviconVersion = fn (string $path) => file_exists(public_path($path)) ? asset($path) . '?v=' . filemtime(public_path($path)) : asset($path); + + // Panel/admin always uses SystemSettings (not SiteSettings / CmsSettings). + $dbFavicon = SystemSettings::value('site.favicon.original'); @endphp - - - - -@if (file_exists(public_path('favicon.svg'))) - +@if (! empty($dbFavicon)) + + + + +@else + + + + + @if (file_exists(public_path('favicon.svg'))) + + @endif + + @endif - - From a9d0b55421275b54971089bc3e8ab787e4fab859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:36 +0300 Subject: [PATCH 12/17] feat(ManageTraits): enhance hasTranslatedInput method to support nested schema arrays for improved input handling --- src/Traits/ManageTraits.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Traits/ManageTraits.php b/src/Traits/ManageTraits.php index 461638d13..24448cea5 100755 --- a/src/Traits/ManageTraits.php +++ b/src/Traits/ManageTraits.php @@ -37,6 +37,12 @@ public function hasTranslatedInput($schema = []) $hasTranslated = true; break; + } elseif (isset($input['schema']) && is_array($input['schema'])) { + $hasTranslated = $this->hasTranslatedInput($input['schema']); + + if ($hasTranslated) { + break; + } } } From 864e5577105dfd678bbef72f9fe2f489b259dcfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 17 Jul 2026 03:37:45 +0300 Subject: [PATCH 13/17] feat(SiteSeo): remove legacy robots.txt input and update UI to direct users to System Settings for SEO management --- vue/src/js/Pages/SiteSeo.vue | 79 ++++++------------------------------ 1 file changed, 13 insertions(+), 66 deletions(-) diff --git a/vue/src/js/Pages/SiteSeo.vue b/vue/src/js/Pages/SiteSeo.vue index 244bb567d..366d77850 100644 --- a/vue/src/js/Pages/SiteSeo.vue +++ b/vue/src/js/Pages/SiteSeo.vue @@ -16,16 +16,6 @@ - - {{ $t('messages.site_seo_db_disabled', 'Database-backed site SEO is disabled (MODULAROUS_CMS_SEO_ROBOTS_USE_SITE_SETTINGS=false). The editor shows the env fallback; saving is ignored until this is enabled.') }} - - - - - +

+ {{ $t('messages.site_seo_robots_moved', 'Global robots.txt is now managed under System Settings → General (SEO section).') }} +

- {{ $t('messages.save', 'Save') }} + {{ $t('messages.open_system_settings', 'Open System Settings') }} -
+