Initial import of space_profiles module

This commit is contained in:
Kelin Rescue Hub
2026-04-04 13:11:50 -04:00
commit 87a59e5a0a
35 changed files with 1627 additions and 0 deletions

76
Events.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace humhub\modules\space_profiles;
use Yii;
class Events
{
public static function onSpaceMenuInit($event): void
{
$space = $event->sender->space ?? null;
if ($space === null || !$space->moduleManager->isEnabled('space_profiles')) {
return;
}
$event->sender->addItem([
'label' => Yii::t('SpaceProfilesModule.base', 'Rescue Profile'),
'group' => 'modules',
'url' => $space->createUrl('/space_profiles/profile/view'),
'icon' => '<i class="fa fa-id-card"></i>',
'sortOrder' => 90,
'isActive' => (
Yii::$app->controller
&& Yii::$app->controller->module
&& Yii::$app->controller->module->id === 'space_profiles'
&& Yii::$app->controller->id === 'profile'
),
]);
}
public static function onRescueSettingsMenuInit($event): void
{
$space = $event->sender->space ?? null;
if ($space === null || !$space->moduleManager->isEnabled('space_profiles')) {
return;
}
$event->sender->addItem([
'label' => Yii::t('SpaceProfilesModule.base', 'Space Profile'),
'url' => $space->createUrl('/space_profiles/settings'),
'sortOrder' => 200,
'isActive' => (
Yii::$app->controller
&& Yii::$app->controller->module
&& Yii::$app->controller->module->id === 'space_profiles'
&& Yii::$app->controller->id === 'settings'
),
]);
}
public static function onSpaceAdminMenuInitFallback($event): void
{
$space = $event->sender->space ?? null;
if ($space === null || !$space->moduleManager->isEnabled('space_profiles')) {
return;
}
if ($space->moduleManager->isEnabled('rescue_foundation') || !$space->isAdmin()) {
return;
}
$event->sender->addItem([
'label' => Yii::t('SpaceProfilesModule.base', 'Space Profile'),
'group' => 'admin',
'url' => $space->createUrl('/space_profiles/settings'),
'icon' => '<i class="fa fa-id-card"></i>',
'sortOrder' => 590,
'isActive' => (
Yii::$app->controller
&& Yii::$app->controller->module
&& Yii::$app->controller->module->id === 'space_profiles'
&& Yii::$app->controller->id === 'settings'
),
]);
}
}

26
Module.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace humhub\modules\space_profiles;
use humhub\modules\content\components\ContentContainerActiveRecord;
use humhub\modules\content\components\ContentContainerModule;
use humhub\modules\space\models\Space;
class Module extends ContentContainerModule
{
public $resourcesPath = 'resources';
public function getContentContainerTypes()
{
return [Space::class];
}
public function getContentContainerConfigUrl(ContentContainerActiveRecord $container)
{
if ($container->moduleManager->isEnabled('rescue_foundation')) {
return $container->createUrl('/rescue_foundation/settings');
}
return $container->createUrl('/space_profiles/settings');
}
}

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Space Profiles Module
Template-driven rescue space profile pages with branding assets and controlled HTML regions for HumHub spaces.
## Requirements
- HumHub 1.14+
- `rescue_foundation` module available in the same HumHub instance
## Installation
Clone into the HumHub modules path using the folder name `space_profiles`:
```bash
git clone https://gitea.kelinreij.duckdns.org/humhub-modules/space-profiles.git /var/www/localhost/htdocs/protected/modules/space_profiles
```
Enable the module in HumHub (Marketplace / Modules UI), then run migrations:
```bash
php /var/www/localhost/htdocs/protected/yii migrate/up --include-module-migrations=1 --interactive=0
```
## Notes
- This module is designed for space-level use.
- If you run in Docker, execute commands inside the HumHub app container.

View File

@@ -0,0 +1,26 @@
<?php
namespace humhub\modules\space_profiles\components;
use Yii;
class TemplateRegistry
{
public const TEMPLATE_RESCUE_CENTER = 'rescue_center';
public static function options(): array
{
return [
self::TEMPLATE_RESCUE_CENTER => Yii::t('SpaceProfilesModule.base', 'Rescue Center (Default)'),
];
}
public static function resolveView(?string $templateKey): string
{
if ($templateKey === self::TEMPLATE_RESCUE_CENTER || empty($templateKey)) {
return 'templates/rescue-center';
}
return 'templates/rescue-center';
}
}

74
components/UrlRule.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
namespace humhub\modules\space_profiles\components;
use humhub\components\ContentContainerUrlRuleInterface;
use humhub\modules\content\components\ContentContainerActiveRecord;
use yii\base\Component;
use yii\web\UrlManager;
use yii\web\UrlRuleInterface;
class UrlRule extends Component implements UrlRuleInterface, ContentContainerUrlRuleInterface
{
public const CONTAINER_ROUTE = 'space_profiles/profile/view';
public const PUBLIC_ROUTE = 'space_profiles/public/view';
private const ROUTE_SEGMENT = 'rescues';
public function parseContentContainerRequest(ContentContainerActiveRecord $container, UrlManager $manager, string $containerUrlPath, array $urlParams)
{
if ($containerUrlPath === self::ROUTE_SEGMENT) {
return [self::CONTAINER_ROUTE, $urlParams];
}
return false;
}
public function createContentContainerUrl(UrlManager $manager, string $containerUrlPath, string $route, array $params)
{
if ($route !== self::CONTAINER_ROUTE) {
return false;
}
$url = $containerUrlPath . '/' . self::ROUTE_SEGMENT;
if (!empty($params)) {
$query = http_build_query($params);
if ($query !== '') {
$url .= '?' . $query;
}
}
return $url;
}
public function createUrl($manager, $route, $params)
{
if ($route !== self::PUBLIC_ROUTE || empty($params['slug'])) {
return false;
}
$slug = urlencode((string)$params['slug']);
unset($params['slug']);
$url = self::ROUTE_SEGMENT . '/' . $slug;
if (!empty($params) && ($query = http_build_query($params)) !== '') {
$url .= '?' . $query;
}
return $url;
}
public function parseRequest($manager, $request)
{
$path = trim($request->getPathInfo(), '/');
if (strpos($path, self::ROUTE_SEGMENT . '/') !== 0) {
return false;
}
$parts = explode('/', $path);
if (empty($parts[1])) {
return false;
}
return [self::PUBLIC_ROUTE, ['slug' => urldecode($parts[1])]];
}
}

19
config.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
use humhub\modules\space\widgets\HeaderControlsMenu;
use humhub\modules\space\widgets\Menu;
use humhub\modules\space_profiles\Events;
return [
'id' => 'space_profiles',
'class' => 'humhub\\modules\\space_profiles\\Module',
'namespace' => 'humhub\\modules\\space_profiles',
'urlManagerRules' => [
['class' => 'humhub\\modules\\space_profiles\\components\\UrlRule'],
],
'events' => [
[Menu::class, Menu::EVENT_INIT, [Events::class, 'onSpaceMenuInit']],
['humhub\\modules\\rescue_foundation\\widgets\\RescueSettingsMenu', 'rescueSettingsMenuInit', [Events::class, 'onRescueSettingsMenuInit']],
[HeaderControlsMenu::class, HeaderControlsMenu::EVENT_INIT, [Events::class, 'onSpaceAdminMenuInitFallback']],
],
];

View File

@@ -0,0 +1,25 @@
<?php
namespace humhub\modules\space_profiles\controllers;
use humhub\modules\content\components\ContentContainerController;
use humhub\modules\space_profiles\models\SpaceProfile;
class ProfileController extends ContentContainerController
{
protected function getAccessRules()
{
return [];
}
public function actionView()
{
$profile = SpaceProfile::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]);
return $this->render('view', [
'space' => $this->contentContainer,
'profile' => $profile,
'isPublicRoute' => false,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace humhub\modules\space_profiles\controllers;
use humhub\components\Controller;
use humhub\modules\space\models\Space;
use humhub\modules\space_profiles\helpers\ProfileAccess;
use humhub\modules\space_profiles\models\SpaceProfile;
use Yii;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
class PublicController extends Controller
{
public function actionView(string $slug)
{
$profile = SpaceProfile::findOne(['slug' => $slug]);
if (!$profile instanceof SpaceProfile) {
throw new NotFoundHttpException('Profile not found.');
}
$space = Space::findOne(['contentcontainer_id' => $profile->contentcontainer_id]);
if (!$space instanceof Space) {
throw new NotFoundHttpException('Space not found.');
}
if (!$space->moduleManager->isEnabled('space_profiles')) {
throw new NotFoundHttpException('Profile not available.');
}
if (!ProfileAccess::canView($space)) {
throw new ForbiddenHttpException('You are not allowed to view this profile.');
}
$this->view->title = Yii::t('SpaceProfilesModule.base', 'Rescue Profile') . ' - ' . $space->name;
return $this->render('view', [
'space' => $space,
'profile' => $profile,
'isPublicRoute' => true,
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace humhub\modules\space_profiles\controllers;
use humhub\modules\content\components\ContentContainerController;
use humhub\modules\content\components\ContentContainerControllerAccess;
use humhub\modules\rescue_foundation\widgets\RescueSettingsMenu;
use humhub\modules\space\models\Space;
use humhub\modules\space_profiles\models\forms\SpaceProfileForm;
use Yii;
use yii\web\UploadedFile;
class SettingsController extends ContentContainerController
{
protected function getAccessRules()
{
return [[ContentContainerControllerAccess::RULE_USER_GROUP_ONLY => [Space::USERGROUP_OWNER, Space::USERGROUP_ADMIN]]];
}
public function actionIndex()
{
$model = new SpaceProfileForm(['contentContainer' => $this->contentContainer]);
if ($model->load(Yii::$app->request->post())) {
$model->iconFile = UploadedFile::getInstance($model, 'iconFile');
$model->backgroundImageFile = UploadedFile::getInstance($model, 'backgroundImageFile');
if ($model->save()) {
$this->view->saved();
return $this->refresh();
}
}
$subNav = null;
if (class_exists(RescueSettingsMenu::class)) {
$subNav = RescueSettingsMenu::widget(['space' => $this->contentContainer]);
}
return $this->render('index', [
'model' => $model,
'subNav' => $subNav,
]);
}
}

37
helpers/ProfileAccess.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace humhub\modules\space_profiles\helpers;
use humhub\modules\space\models\Space;
use humhub\modules\user\models\User;
use Yii;
class ProfileAccess
{
public static function canView(Space $space, User $user = null): bool
{
if ($space->status === Space::STATUS_DISABLED) {
return false;
}
$user = $user ?: (!Yii::$app->user->isGuest ? Yii::$app->user->getIdentity() : null);
if ($user && method_exists($user, 'isSystemAdmin') && $user->isSystemAdmin()) {
return true;
}
if ($user && method_exists($space, 'isMember') && $space->isMember($user)) {
return true;
}
if ($space->visibility === Space::VISIBILITY_ALL) {
return true;
}
if ($space->visibility === Space::VISIBILITY_REGISTERED_ONLY) {
return $user !== null;
}
return false;
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace humhub\modules\space_profiles\helpers;
use yii\helpers\HtmlPurifier;
class ProfileHtmlSanitizer
{
public static function sanitize(?string $html): string
{
$value = (string)$html;
if ($value === '') {
return '';
}
$styleBlocks = [];
if (preg_match_all('#<style\\b[^>]*>(.*?)</style>#is', $value, $matches)) {
$styleBlocks = $matches[1] ?? [];
}
$value = preg_replace('#<style\\b[^>]*>.*?</style>#is', '', $value) ?? '';
$value = preg_replace('#<script\\b[^>]*>.*?</script>#is', '', $value) ?? '';
$value = preg_replace("/\\son[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/i", '', $value) ?? '';
$value = preg_replace('/javascript\\s*:/i', '', $value) ?? '';
$value = preg_replace('/expression\\s*\\(/i', '', $value) ?? '';
$purified = HtmlPurifier::process($value, [
'HTML.Allowed' => 'div,p,br,span,strong,em,b,i,u,ul,ol,li,h1,h2,h3,h4,h5,h6,a[href|title|target],img[src|alt|title|width|height],blockquote,hr,table,thead,tbody,tr,th,td,code,pre',
'Attr.EnableID' => false,
'CSS.Trusted' => false,
'URI.AllowedSchemes' => [
'http' => true,
'https' => true,
'mailto' => true,
],
]);
$scopedCss = [];
foreach ($styleBlocks as $styleBlock) {
$css = self::scopeCss((string)$styleBlock);
if (trim($css) !== '') {
$scopedCss[] = $css;
}
}
if (empty($scopedCss)) {
return $purified;
}
return $purified . "\n<style>\n" . implode("\n", $scopedCss) . "\n</style>";
}
private static function scopeCss(string $css): string
{
$css = preg_replace('/@import\\s+[^;]+;/i', '', $css) ?? '';
$css = preg_replace('/javascript\\s*:/i', '', $css) ?? '';
$css = preg_replace('/expression\\s*\\(/i', '', $css) ?? '';
$chunks = explode('}', $css);
$scoped = [];
foreach ($chunks as $chunk) {
if (trim($chunk) === '' || strpos($chunk, '{') === false) {
continue;
}
[$selectors, $body] = explode('{', $chunk, 2);
$selectors = trim($selectors);
if ($selectors === '') {
continue;
}
if (strpos($selectors, '@') === 0) {
$scoped[] = $selectors . '{' . $body . '}';
continue;
}
$selectorParts = array_filter(array_map('trim', explode(',', $selectors)));
$scopedSelectors = [];
foreach ($selectorParts as $selector) {
$selector = str_replace(':root', '.rescue-profile-scope', $selector);
if (preg_match('/^(html|body)\\b/i', $selector)) {
$selector = preg_replace('/^(html|body)\\b/i', '.rescue-profile-scope', $selector) ?? '.rescue-profile-scope';
}
if (strpos($selector, '.rescue-profile-scope') !== 0) {
$selector = '.rescue-profile-scope ' . $selector;
}
$scopedSelectors[] = $selector;
}
if (!empty($scopedSelectors)) {
$scoped[] = implode(', ', $scopedSelectors) . '{' . $body . '}';
}
}
return implode("\\n", $scoped);
}
}

View File

@@ -0,0 +1,104 @@
<?php
use humhub\components\Migration;
class m260401_130000_initial extends Migration
{
public function safeUp()
{
$this->safeCreateTable('rescue_space_profile', [
'id' => $this->primaryKey(),
'contentcontainer_id' => $this->integer()->notNull(),
'rescue_name' => $this->string(190)->notNull(),
'address' => $this->string(255)->notNull(),
'city' => $this->string(120)->notNull(),
'state' => $this->string(2)->notNull(),
'zip' => $this->string(10)->notNull(),
'email' => $this->string(190)->notNull(),
'phone' => $this->string(32)->notNull(),
'animals_we_accept' => $this->text()->notNull(),
'description' => $this->text()->notNull(),
'mission_statement' => $this->text()->notNull(),
'template_key' => $this->string(64)->defaultValue('rescue_center')->notNull(),
'header_html' => $this->text()->null(),
'body_html' => $this->text()->null(),
'footer_html' => $this->text()->null(),
'icon_path' => $this->string(255)->null(),
'background_image_path' => $this->string(255)->null(),
'created_at' => $this->dateTime()->null(),
'updated_at' => $this->dateTime()->null(),
]);
$this->safeCreateIndex('idx_rescue_space_profile_container', 'rescue_space_profile', 'contentcontainer_id', true);
$this->safeAddForeignKey(
'fk_rescue_space_profile_container',
'rescue_space_profile',
'contentcontainer_id',
'contentcontainer',
'id',
'CASCADE'
);
$this->seedFieldMetadata();
}
public function safeDown()
{
$this->safeDropTable('rescue_space_profile');
}
private function seedFieldMetadata(): void
{
if ($this->db->getSchema()->getTableSchema('rescue_field_definition', true) === null) {
return;
}
$createdAt = date('Y-m-d H:i:s');
$rows = [
['rescue_name', 'Rescue name', 'text', 1, 1, 110],
['address', 'Address', 'text', 1, 1, 120],
['city', 'City', 'text', 1, 1, 130],
['state', 'State', 'text', 1, 1, 140],
['zip', 'ZIP', 'text', 1, 1, 150],
['email', 'Email', 'email', 1, 1, 160],
['phone', 'Phone', 'text', 1, 1, 170],
['animals_we_accept', 'Animals we accept', 'textarea', 1, 1, 180],
['description', 'Description', 'textarea', 1, 1, 190],
['mission_statement', 'Mission statement', 'textarea', 1, 1, 200],
['template_key', 'Template', 'select', 1, 1, 205],
['header_html', 'Header HTML', 'html', 0, 1, 210],
['body_html', 'Body HTML', 'html', 0, 1, 220],
['footer_html', 'Footer HTML', 'html', 0, 1, 230],
['icon_path', 'Icon', 'image', 0, 1, 240],
['background_image_path', 'Background image', 'image', 0, 1, 250],
];
foreach ($rows as $row) {
[$fieldKey, $label, $inputType, $required, $isCore, $sortOrder] = $row;
$exists = (new \yii\db\Query())
->from('rescue_field_definition')
->where(['module_id' => 'space_profiles', 'field_key' => $fieldKey])
->exists($this->db);
if ($exists) {
continue;
}
$this->insert('rescue_field_definition', [
'module_id' => 'space_profiles',
'group_key' => 'space_profile',
'field_key' => $fieldKey,
'label' => $label,
'input_type' => $inputType,
'required' => $required,
'is_core' => $isCore,
'is_active' => 1,
'visibility' => 'public',
'options' => '{}',
'sort_order' => $sortOrder,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
use humhub\components\Migration;
class m260401_141000_add_template_key extends Migration
{
public function safeUp()
{
$schema = $this->db->getSchema()->getTableSchema('rescue_space_profile', true);
if ($schema === null) {
return;
}
if (!isset($schema->columns['template_key'])) {
$this->addColumn('rescue_space_profile', 'template_key', $this->string(64)->defaultValue('rescue_center')->notNull());
}
}
public function safeDown()
{
$schema = $this->db->getSchema()->getTableSchema('rescue_space_profile', true);
if ($schema !== null && isset($schema->columns['template_key'])) {
$this->dropColumn('rescue_space_profile', 'template_key');
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
use humhub\components\Migration;
class m260401_200000_add_slug extends Migration
{
public function safeUp()
{
$schema = $this->db->getSchema()->getTableSchema('rescue_space_profile', true);
if ($schema === null) {
return;
}
if (!isset($schema->columns['slug'])) {
$this->addColumn('rescue_space_profile', 'slug', $this->string(190)->null()->after('mission_statement'));
}
$rows = (new \yii\db\Query())
->select([
'id' => 'rsp.id',
'rescue_name' => 'rsp.rescue_name',
'space_name' => 's.name',
])
->from(['rsp' => 'rescue_space_profile'])
->leftJoin(['cc' => 'contentcontainer'], 'cc.id = rsp.contentcontainer_id')
->leftJoin(['s' => 'space'], 's.contentcontainer_id = cc.id')
->orderBy(['rsp.id' => SORT_ASC])
->all($this->db);
foreach ($rows as $row) {
$source = trim((string)($row['space_name'] ?? ''));
if ($source === '') {
$source = trim((string)($row['rescue_name'] ?? ''));
}
$slug = $this->createUniqueSlug($source, (int)$row['id']);
$this->update('rescue_space_profile', ['slug' => $slug], ['id' => (int)$row['id']]);
}
$this->alterColumn('rescue_space_profile', 'slug', $this->string(190)->notNull());
$this->safeCreateIndex('ux_rescue_space_profile_slug', 'rescue_space_profile', 'slug', true);
}
public function safeDown()
{
$schema = $this->db->getSchema()->getTableSchema('rescue_space_profile', true);
if ($schema === null || !isset($schema->columns['slug'])) {
return;
}
$this->safeDropIndex('ux_rescue_space_profile_slug', 'rescue_space_profile');
$this->dropColumn('rescue_space_profile', 'slug');
}
private function createUniqueSlug(string $source, int $rowId): string
{
$base = $this->normalizeToSlug($source);
$candidate = $base;
$counter = 1;
while ($this->slugExists($candidate, $rowId)) {
$candidate = $base . '-' . $counter;
$counter++;
}
return $candidate;
}
private function normalizeToSlug(string $value): string
{
$normalized = trim($value);
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
if ($ascii !== false) {
$normalized = $ascii;
}
$slug = strtolower($normalized);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : 'rescue';
}
private function slugExists(string $slug, int $rowId): bool
{
return (new \yii\db\Query())
->from('rescue_space_profile')
->where(['slug' => $slug])
->andWhere(['!=', 'id', $rowId])
->exists($this->db);
}
}

View File

@@ -0,0 +1,64 @@
<?php
use humhub\components\Migration;
class m260401_201000_set_rescues_default_home extends Migration
{
public function safeUp()
{
$rows = (new \yii\db\Query())
->select(['contentcontainer_id', 'url', 'guid'])
->from('space')
->where(['in', 'contentcontainer_id', (new \yii\db\Query())
->select('contentcontainer_id')
->from('rescue_space_profile')])
->all($this->db);
foreach ($rows as $row) {
$spacePath = trim((string)($row['url'] ?? ''));
if ($spacePath === '') {
$spacePath = trim((string)($row['guid'] ?? ''));
}
if ($spacePath === '') {
continue;
}
$homeUrl = '/s/' . $spacePath . '/rescues';
$contentContainerId = (int)$row['contentcontainer_id'];
$this->upsert('contentcontainer_setting', [
'module_id' => 'space',
'contentcontainer_id' => $contentContainerId,
'name' => 'indexUrl',
'value' => $homeUrl,
], [
'value' => $homeUrl,
]);
$this->upsert('contentcontainer_setting', [
'module_id' => 'space',
'contentcontainer_id' => $contentContainerId,
'name' => 'indexGuestUrl',
'value' => $homeUrl,
], [
'value' => $homeUrl,
]);
}
}
public function safeDown()
{
$this->delete('contentcontainer_setting', [
'and',
['module_id' => 'space', 'name' => 'indexUrl'],
['like', 'value', '/s/%/rescues', false],
]);
$this->delete('contentcontainer_setting', [
'and',
['module_id' => 'space', 'name' => 'indexGuestUrl'],
['like', 'value', '/s/%/rescues', false],
]);
}
}

33
models/SpaceProfile.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace humhub\modules\space_profiles\models;
use humhub\components\ActiveRecord;
use humhub\modules\space_profiles\components\TemplateRegistry;
class SpaceProfile extends ActiveRecord
{
public static function tableName()
{
return 'rescue_space_profile';
}
public function rules()
{
return [
[['contentcontainer_id', 'rescue_name', 'address', 'city', 'state', 'zip', 'email', 'phone', 'animals_we_accept', 'description', 'mission_statement', 'template_key', 'slug'], 'required'],
[['contentcontainer_id'], 'integer'],
[['animals_we_accept', 'description', 'mission_statement', 'header_html', 'body_html', 'footer_html'], 'string'],
[['template_key'], 'in', 'range' => array_keys(TemplateRegistry::options())],
[['rescue_name', 'email', 'slug'], 'string', 'max' => 190],
[['address', 'icon_path', 'background_image_path'], 'string', 'max' => 255],
[['template_key'], 'string', 'max' => 64],
[['city'], 'string', 'max' => 120],
[['state'], 'string', 'max' => 2],
[['zip'], 'string', 'max' => 10],
[['phone'], 'string', 'max' => 32],
[['contentcontainer_id'], 'unique'],
[['slug'], 'unique'],
];
}
}

View File

@@ -0,0 +1,291 @@
<?php
namespace humhub\modules\space_profiles\models\forms;
use humhub\modules\content\components\ContentContainerActiveRecord;
use humhub\modules\rescue_foundation\components\UploadStandards;
use humhub\modules\rescue_foundation\components\ValidationStandards;
use humhub\modules\space\models\Space;
use humhub\modules\space_profiles\components\TemplateRegistry;
use humhub\modules\space_profiles\helpers\ProfileHtmlSanitizer;
use humhub\modules\space_profiles\models\SpaceProfile;
use Yii;
use yii\base\Model;
use yii\helpers\FileHelper;
use yii\web\UploadedFile;
class SpaceProfileForm extends Model
{
public ContentContainerActiveRecord $contentContainer;
public string $rescue_name = '';
public string $address = '';
public string $city = '';
public string $state = '';
public string $zip = '';
public string $email = '';
public string $phone = '';
public string $animals_we_accept = '';
public string $description = '';
public string $mission_statement = '';
public string $template_key = TemplateRegistry::TEMPLATE_RESCUE_CENTER;
public ?string $header_html = null;
public ?string $body_html = null;
public ?string $footer_html = null;
public UploadedFile|string|null $iconFile = null;
public UploadedFile|string|null $backgroundImageFile = null;
public bool $removeIcon = false;
public bool $removeBackgroundImage = false;
private ?SpaceProfile $profile = null;
public function init()
{
parent::init();
$this->profile = SpaceProfile::findOne(['contentcontainer_id' => $this->contentContainer->contentcontainer_id]);
if ($this->profile) {
$this->address = (string)$this->profile->address;
$this->city = (string)$this->profile->city;
$this->state = (string)$this->profile->state;
$this->zip = (string)$this->profile->zip;
$this->email = (string)$this->profile->email;
$this->phone = (string)$this->profile->phone;
$this->animals_we_accept = (string)$this->profile->animals_we_accept;
$this->mission_statement = (string)$this->profile->mission_statement;
$this->template_key = !empty($this->profile->template_key)
? (string)$this->profile->template_key
: TemplateRegistry::TEMPLATE_RESCUE_CENTER;
$this->header_html = $this->profile->header_html;
$this->body_html = $this->profile->body_html;
$this->footer_html = $this->profile->footer_html;
}
$this->rescue_name = $this->getSpaceName();
$this->description = $this->getSpaceDescription();
}
public function rules()
{
$contactRules = ValidationStandards::contactRules([
'email' => 'email',
'phone' => 'phone',
'state' => 'state',
'zip' => 'zip',
]);
return array_merge([
[['address', 'city', 'state', 'zip', 'email', 'phone', 'animals_we_accept', 'mission_statement', 'template_key'], 'required'],
[['animals_we_accept', 'description', 'mission_statement', 'header_html', 'body_html', 'footer_html'], 'string'],
[['template_key'], 'in', 'range' => array_keys(TemplateRegistry::options())],
[['rescue_name', 'email'], 'string', 'max' => 190],
[['address'], 'string', 'max' => 255],
[['city'], 'string', 'max' => 120],
[['state'], 'string', 'max' => 2],
[['zip'], 'string', 'max' => 10],
[['phone'], 'string', 'max' => 32],
[['template_key'], 'string', 'max' => 64],
[['removeIcon', 'removeBackgroundImage'], 'boolean'],
[['iconFile', 'backgroundImageFile'], 'file',
'skipOnEmpty' => true,
'extensions' => UploadStandards::imageExtensions(),
'checkExtensionByMimeType' => true,
'mimeTypes' => UploadStandards::imageMimeTypes(),
'maxSize' => UploadStandards::IMAGE_MAX_BYTES,
],
], $contactRules);
}
public function save(): bool
{
if (!$this->validate()) {
return false;
}
$persistedDescription = $this->resolvePersistedDescription();
$profile = $this->profile ?: new SpaceProfile();
$profile->contentcontainer_id = $this->contentContainer->contentcontainer_id;
$profile->rescue_name = $this->getSpaceName();
$profile->address = trim($this->address);
$profile->city = trim($this->city);
$profile->state = strtoupper(trim($this->state));
$profile->zip = trim($this->zip);
$profile->email = trim($this->email);
$profile->phone = trim($this->phone);
$profile->animals_we_accept = trim($this->animals_we_accept);
$profile->description = $persistedDescription;
$profile->mission_statement = trim($this->mission_statement);
$profile->slug = $this->generateUniqueSlug();
$profile->template_key = $this->template_key;
$profile->header_html = ProfileHtmlSanitizer::sanitize($this->header_html);
$profile->body_html = ProfileHtmlSanitizer::sanitize($this->body_html);
$profile->footer_html = ProfileHtmlSanitizer::sanitize($this->footer_html);
if ($this->removeIcon) {
$this->deleteStoredFile($profile->icon_path);
$profile->icon_path = null;
}
if ($this->removeBackgroundImage) {
$this->deleteStoredFile($profile->background_image_path);
$profile->background_image_path = null;
}
if ($this->iconFile instanceof UploadedFile) {
$this->deleteStoredFile($profile->icon_path);
$profile->icon_path = $this->storeImage($this->iconFile, 'icon');
}
if ($this->backgroundImageFile instanceof UploadedFile) {
$this->deleteStoredFile($profile->background_image_path);
$profile->background_image_path = $this->storeImage($this->backgroundImageFile, 'background');
}
if (!$profile->save()) {
$this->addErrors($profile->getErrors());
return false;
}
$this->setDefaultHomePage();
$this->profile = $profile;
return true;
}
public function getProfile(): ?SpaceProfile
{
return $this->profile;
}
public function getTemplateOptions(): array
{
return TemplateRegistry::options();
}
private function storeImage(UploadedFile $file, string $prefix): ?string
{
$relativeDir = '/uploads/space-profiles/' . $this->contentContainer->contentcontainer_id;
$absoluteDir = Yii::getAlias('@webroot') . $relativeDir;
FileHelper::createDirectory($absoluteDir);
$random = Yii::$app->security->generateRandomString(8);
$fileName = $prefix . '-' . time() . '-' . $random . '.' . strtolower((string)$file->extension);
$absolutePath = $absoluteDir . '/' . $fileName;
if (!$file->saveAs($absolutePath)) {
return null;
}
return $relativeDir . '/' . $fileName;
}
private function deleteStoredFile(?string $relativePath): void
{
if (empty($relativePath)) {
return;
}
$absolutePath = Yii::getAlias('@webroot') . $relativePath;
if (is_file($absolutePath)) {
@unlink($absolutePath);
}
}
private function buildBaseSlug(): string
{
$source = $this->getSpaceName();
$normalized = trim((string)$source);
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
if ($ascii !== false) {
$normalized = $ascii;
}
$slug = strtolower($normalized);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : 'rescue';
}
private function generateUniqueSlug(): string
{
$base = $this->buildBaseSlug();
$candidate = $base;
$suffix = 1;
while ($this->slugExists($candidate)) {
$candidate = $base . '-' . $suffix;
$suffix++;
}
return $candidate;
}
private function slugExists(string $slug): bool
{
$query = SpaceProfile::find()->where(['slug' => $slug]);
if ($this->profile && !$this->profile->isNewRecord) {
$query->andWhere(['!=', 'id', (int)$this->profile->id]);
}
return $query->exists();
}
private function setDefaultHomePage(): void
{
if (!$this->contentContainer instanceof Space) {
return;
}
$defaultUrl = $this->contentContainer->createUrl('/space_profiles/profile/view');
$settings = $this->contentContainer->getSettings();
$settings->set('indexUrl', $defaultUrl);
$settings->set('indexGuestUrl', $defaultUrl);
}
private function getSpaceName(): string
{
if ($this->contentContainer instanceof Space) {
return trim((string)$this->contentContainer->name);
}
return trim($this->rescue_name);
}
private function getSpaceDescription(): string
{
if ($this->contentContainer instanceof Space) {
$about = trim((string)$this->contentContainer->about);
if ($about !== '') {
return $about;
}
return trim((string)$this->contentContainer->description);
}
return trim($this->description);
}
private function resolvePersistedDescription(): string
{
$description = $this->getSpaceDescription();
if ($description !== '') {
return $description;
}
$mission = trim($this->mission_statement);
if ($mission !== '') {
return $mission;
}
$animals = trim($this->animals_we_accept);
if ($animals !== '') {
return $animals;
}
return $this->getSpaceName();
}
}

15
module.json Normal file
View File

@@ -0,0 +1,15 @@
{
"id": "space_profiles",
"name": "Space Profiles",
"description": "Template-driven rescue space profile pages with branding and controlled HTML regions.",
"keywords": ["space", "profile", "rescue", "branding"],
"version": "0.1.0",
"humhub": {
"minVersion": "1.14"
},
"authors": [
{
"name": "Kelin Rescue Hub"
}
]
}

19
tests/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Space Profiles Test Notes
This module includes a unit suite scaffold under `tests/codeception/unit`.
Current unit coverage targets:
- HTML sanitization hardening regressions
- Public/profile access helper behavior
- Form-level validation behavior for core profile fields
## Run
In a HumHub development environment with Codeception installed:
```bash
cd protected
php vendor/bin/codecept run -c modules/space_profiles/tests/codeception.yml unit
```
If `vendor/bin/codecept` is not present, install dev dependencies for the HumHub environment first.

18
tests/codeception.yml Normal file
View File

@@ -0,0 +1,18 @@
actor: Tester
namespace: space_profiles
bootstrap: _bootstrap.php
settings:
suite_class: \PHPUnit_Framework_TestSuite
colors: true
shuffle: false
memory_limit: 1024M
log: true
backup_globals: true
paths:
tests: codeception
log: codeception/_output
data: codeception/_data
helpers: codeception/_support
envs: ../../../humhub/tests/config/env
config:
test_entry_url: http://localhost:8080/index-test.php

View File

@@ -0,0 +1,26 @@
<?php
/*
* Initial module test bootstrap. Loads default HumHub test bootstrap.
*/
$testRoot = dirname(__DIR__);
\Codeception\Configuration::append(['test_root' => $testRoot]);
codecept_debug('Module root: ' . $testRoot);
$humhubPath = getenv('HUMHUB_PATH');
if ($humhubPath === false) {
$moduleConfig = require $testRoot . '/config/test.php';
if (isset($moduleConfig['humhub_root'])) {
$humhubPath = $moduleConfig['humhub_root'];
} else {
$humhubPath = dirname(__DIR__, 5);
}
}
\Codeception\Configuration::append(['humhub_root' => $humhubPath]);
codecept_debug('HumHub Root: ' . $humhubPath);
$globalConfig = require $humhubPath . '/protected/humhub/tests/codeception/_loadConfig.php';
require $globalConfig['humhub_root'] . '/protected/humhub/tests/codeception/_bootstrap.php';

View File

@@ -0,0 +1,7 @@
<?php
namespace space_profiles;
class UnitTester extends \humhub\tests\codeception\_support\HumHubDbTestCase
{
}

View File

@@ -0,0 +1,3 @@
<?php
return \tests\codeception\_support\HumHubTestConfiguration::getSuiteConfig('unit');

View File

@@ -0,0 +1,9 @@
class_name: UnitTester
modules:
enabled:
- tests\codeception\_support\CodeHelper
- Yii2
config:
Yii2:
configFile: 'codeception/config/unit.php'
transaction: false

View File

@@ -0,0 +1,69 @@
<?php
namespace space_profiles;
use humhub\modules\space\models\Space;
use humhub\modules\space_profiles\helpers\ProfileAccess;
use humhub\modules\user\models\User;
class ProfileAccessTest extends UnitTester
{
public function testPublicSpaceAllowsGuest(): void
{
$space = $this->createSpaceMock(Space::VISIBILITY_ALL, true, false);
$this->assertTrue(ProfileAccess::canView($space, null));
}
public function testMembersOnlyBlocksGuestAndAllowsUser(): void
{
$space = $this->createSpaceMock(Space::VISIBILITY_REGISTERED_ONLY, true, false);
$user = $this->createUserMock(false);
$this->assertFalse(ProfileAccess::canView($space, null));
$this->assertTrue(ProfileAccess::canView($space, $user));
}
public function testPrivateSpaceRequiresMembership(): void
{
$user = $this->createUserMock(false);
$nonMemberSpace = $this->createSpaceMock(Space::VISIBILITY_NONE, true, false);
$memberSpace = $this->createSpaceMock(Space::VISIBILITY_NONE, true, true);
$this->assertFalse(ProfileAccess::canView($nonMemberSpace, $user));
$this->assertTrue(ProfileAccess::canView($memberSpace, $user));
}
public function testDisabledSpaceDenied(): void
{
$space = $this->createSpaceMock(Space::VISIBILITY_ALL, false, true);
$this->assertFalse(ProfileAccess::canView($space, $this->createUserMock(true)));
}
private function createSpaceMock(int $visibility, bool $enabled, bool $isMember): Space
{
$space = $this->getMockBuilder(Space::class)
->disableOriginalConstructor()
->onlyMethods(['isMember'])
->getMock();
$space->visibility = $visibility;
$space->status = $enabled ? Space::STATUS_ENABLED : Space::STATUS_ARCHIVED;
$space->method('isMember')->willReturn($isMember);
return $space;
}
private function createUserMock(bool $isAdmin): User
{
$user = $this->getMockBuilder(User::class)
->disableOriginalConstructor()
->onlyMethods(['isSystemAdmin'])
->getMock();
$user->method('isSystemAdmin')->willReturn($isAdmin);
return $user;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace space_profiles;
use humhub\modules\space_profiles\helpers\ProfileHtmlSanitizer;
class ProfileHtmlSanitizerTest extends UnitTester
{
public function testSanitizeRemovesScriptsAndHandlers(): void
{
$input = '<script>alert(1)</script><div class="hdr" onclick="alert(2)">OK</div>';
$output = ProfileHtmlSanitizer::sanitize($input);
$this->assertStringNotContainsString('<script', $output);
$this->assertStringNotContainsString('onclick=', $output);
$this->assertStringContainsString('<div class="hdr">OK</div>', $output);
}
public function testSanitizeStripsJavascriptUris(): void
{
$input = '<a href="javascript:alert(1)">bad</a><a href="https://example.org">good</a>';
$output = ProfileHtmlSanitizer::sanitize($input);
$this->assertStringNotContainsString('javascript:', $output);
$this->assertStringContainsString('https://example.org', $output);
}
public function testSanitizeScopesEmbeddedCss(): void
{
$input = '<style>body{color:red}.hdr{font-weight:bold}</style><div class="hdr">Title</div>';
$output = ProfileHtmlSanitizer::sanitize($input);
$this->assertStringContainsString('.rescue-profile-scope', $output);
$this->assertStringContainsString('.rescue-profile-scope .hdr', $output);
$this->assertStringNotContainsString('<style>body{color:red}', $output);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace space_profiles;
use humhub\modules\space\models\Space;
use humhub\modules\space_profiles\models\forms\SpaceProfileForm;
class SpaceProfileFormTest extends UnitTester
{
public function testValidMinimalDataPassesValidation(): void
{
$space = Space::findOne(['id' => 2]);
$this->assertNotNull($space, 'Expected fixture space with id=2 to exist.');
$form = new SpaceProfileForm(['contentContainer' => $space]);
$form->profile_name = 'Test Rescue Space';
$form->city = 'Boston';
$form->state = 'MA';
$form->zip_code = '02110';
$form->contact_email = 'team@example.org';
$form->contact_phone = '(617) 555-1212';
$form->website_url = 'https://example.org';
$this->assertTrue($form->validate());
}
public function testStateMustBeTwoCharacters(): void
{
$space = Space::findOne(['id' => 2]);
$this->assertNotNull($space, 'Expected fixture space with id=2 to exist.');
$form = new SpaceProfileForm(['contentContainer' => $space]);
$form->profile_name = 'Invalid State Case';
$form->state = 'Massachusetts';
$this->assertFalse($form->validate(['state']));
$this->assertArrayHasKey('state', $form->getErrors());
}
public function testContactEmailMustBeValid(): void
{
$space = Space::findOne(['id' => 2]);
$this->assertNotNull($space, 'Expected fixture space with id=2 to exist.');
$form = new SpaceProfileForm(['contentContainer' => $space]);
$form->profile_name = 'Invalid Email Case';
$form->contact_email = 'not-an-email';
$this->assertFalse($form->validate(['contact_email']));
$this->assertArrayHasKey('contact_email', $form->getErrors());
}
}

View File

@@ -0,0 +1 @@
<?php

6
tests/config/common.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
/*
* Shared config for all suites.
*/
return [];

8
tests/config/test.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
'modules' => ['space_profiles'],
'fixtures' => [
'default',
],
];

3
tests/config/unit.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
return [];

View File

@@ -0,0 +1,45 @@
<?php
use yii\helpers\Html;
/* @var \humhub\modules\space\models\Space $space */
/* @var \humhub\modules\space_profiles\models\SpaceProfile $profile */
/* @var string $searchBlockWidget */
?>
<div class="media" style="margin-bottom:20px;">
<div class="media-left" style="padding-right:15px;">
<?php if (!empty($profile->icon_path)): ?>
<?= Html::img($profile->icon_path, ['style' => 'width:96px;height:96px;object-fit:cover;border-radius:10px;']) ?>
<?php else: ?>
<div style="width:96px;height:96px;border-radius:10px;background:#f3f5f7;display:flex;align-items:center;justify-content:center;">
<i class="fa fa-paw fa-2x text-muted"></i>
</div>
<?php endif; ?>
</div>
<div class="media-body">
<h2 style="margin-top:0;margin-bottom:8px;"><?= Html::encode($space->name) ?></h2>
</div>
</div>
<?php if (!empty($profile->header_html)): ?>
<section style="margin-bottom:20px;"><?= $profile->header_html ?></section>
<?php endif; ?>
<?php if (!empty($profile->body_html)): ?>
<section style="margin-bottom:20px;"><?= $profile->body_html ?></section>
<?php endif; ?>
<section style="margin-bottom:20px;">
<?php if (class_exists($searchBlockWidget)): ?>
<?= $searchBlockWidget::widget(['contentContainer' => $space]) ?>
<?php else: ?>
<div class="well well-sm" style="margin-bottom:0;">
<?= Yii::t('SpaceProfilesModule.base', 'Animal Management plugin integration point is ready. The block will appear here when that plugin is installed.') ?>
</div>
<?php endif; ?>
</section>
<?php if (!empty($profile->footer_html)): ?>
<section><?= $profile->footer_html ?></section>
<?php endif; ?>

93
views/profile/view.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
use humhub\modules\space\models\Space;
use humhub\modules\space_profiles\components\TemplateRegistry;
use humhub\modules\space_profiles\models\SpaceProfile;
use yii\helpers\Html;
/* @var Space $space */
/* @var SpaceProfile|null $profile */
/* @var bool $isPublicRoute */
$backgroundStyle = '';
if ($profile && !empty($profile->background_image_path)) {
$backgroundStyle = 'background-image:url(' . Html::encode($profile->background_image_path) . ');background-size:cover;background-position:center;';
}
$searchBlockWidget = '\\humhub\\modules\\animal_management\\widgets\\SearchAnimalProfilesBlock';
$templateView = TemplateRegistry::resolveView($profile ? $profile->template_key : null);
$spaceDescription = trim((string)$space->about);
if ($spaceDescription === '') {
$spaceDescription = trim((string)$space->description);
}
if ($spaceDescription === '') {
$spaceDescription = trim((string)($profile?->description ?? ''));
}
?>
<div class="panel panel-default rescue-profile-scope" style="<?= $backgroundStyle ?>">
<div class="panel-body" style="background:rgba(255,255,255,0.93);border-radius:8px;">
<?php if (!empty($isPublicRoute) && $space->visibility !== Space::VISIBILITY_ALL): ?>
<div class="alert alert-warning">
<?= Yii::t('SpaceProfilesModule.base', 'This profile is visible to signed-in users based on the space privacy setting.') ?>
</div>
<?php endif; ?>
<?php if (!$profile): ?>
<div class="alert alert-info">
<?= Yii::t('SpaceProfilesModule.base', 'This rescue has not published its profile yet.') ?>
</div>
<?php else: ?>
<?= $this->render($templateView, [
'space' => $space,
'profile' => $profile,
'searchBlockWidget' => $searchBlockWidget,
]) ?>
<?php endif; ?>
</div>
</div>
<?php $this->beginBlock('sidebar'); ?>
<div class="panel panel-default">
<div class="panel-body">
<?php if (!$profile): ?>
<div class="text-muted">
<?= Yii::t('SpaceProfilesModule.base', 'Contact details will appear after the profile is published.') ?>
</div>
<?php else: ?>
<div style="font-weight:700;font-size:18px;text-align:center;margin-bottom:12px;">
<?= Html::encode($space->name) ?>
</div>
<div><?= Html::encode($profile->address) ?></div>
<div style="margin-bottom:10px;"><?= Html::encode($profile->city) ?>, <?= Html::encode($profile->state) ?> <?= Html::encode($profile->zip) ?></div>
<div style="margin-bottom:6px;">
<strong><?= Yii::t('SpaceProfilesModule.base', 'Email') ?>:</strong>
<a href="mailto:<?= Html::encode($profile->email) ?>"><?= Html::encode($profile->email) ?></a>
</div>
<div>
<strong><?= Yii::t('SpaceProfilesModule.base', 'Phone') ?>:</strong>
<a href="tel:<?= Html::encode(preg_replace('/[^0-9+]/', '', (string)$profile->phone) ?? '') ?>"><?= Html::encode($profile->phone) ?></a>
</div>
<?php if ($spaceDescription !== '' || trim((string)$profile->mission_statement) !== '' || trim((string)$profile->animals_we_accept) !== ''): ?>
<hr style="margin:12px 0;">
<?php endif; ?>
<?php if ($spaceDescription !== ''): ?>
<div style="margin-bottom:10px;"><?= nl2br(Html::encode($spaceDescription)) ?></div>
<?php endif; ?>
<?php if (trim((string)$profile->mission_statement) !== ''): ?>
<div style="margin-bottom:4px;"><strong><?= Yii::t('SpaceProfilesModule.base', 'Mission Statement') ?>:</strong></div>
<div style="margin-bottom:10px;"><?= nl2br(Html::encode($profile->mission_statement)) ?></div>
<?php endif; ?>
<?php if (trim((string)$profile->animals_we_accept) !== ''): ?>
<div style="margin-bottom:4px;"><strong><?= Yii::t('SpaceProfilesModule.base', 'Animals We Accept') ?>:</strong></div>
<div><?= nl2br(Html::encode($profile->animals_we_accept)) ?></div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php $this->endBlock(); ?>

10
views/public/view.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
/* @var \humhub\modules\space\models\Space $space */
/* @var \humhub\modules\space_profiles\models\SpaceProfile|null $profile */
echo $this->render('@space_profiles/views/profile/view', [
'space' => $space,
'profile' => $profile,
'isPublicRoute' => true,
]);

95
views/settings/index.php Normal file
View File

@@ -0,0 +1,95 @@
<?php
use humhub\modules\rescue_foundation\components\UploadStandards;
use humhub\modules\space_profiles\models\forms\SpaceProfileForm;
use humhub\widgets\Button;
use yii\bootstrap\ActiveForm;
use yii\helpers\Html;
/* @var SpaceProfileForm $model */
/* @var string|null $subNav */
$profile = $model->getProfile();
?>
<div class="panel panel-default">
<div class="panel-heading"><?= Yii::t('SpaceProfilesModule.base', '<strong>Space Profile</strong> Settings') ?></div>
<?php if (!empty($subNav)): ?>
<?= $subNav ?>
<?php endif; ?>
<div class="panel-body">
<div class="help-block">
<?= Yii::t('SpaceProfilesModule.base', 'Configure your rescue profile content, optional HTML regions, and branding assets.') ?>
</div>
<div class="alert alert-info">
<?= Yii::t('SpaceProfilesModule.base', 'Rescue name and description are inherited from this Space\'s name and about/description settings.') ?>
</div>
<?php $form = ActiveForm::begin(['options' => ['enctype' => 'multipart/form-data']]); ?>
<?= $form->errorSummary($model, ['class' => 'alert alert-danger']) ?>
<?= $form->field($model, 'template_key')->dropDownList($model->getTemplateOptions()) ?>
<?= $form->field($model, 'address') ?>
<div class="row">
<div class="col-md-4"><?= $form->field($model, 'city') ?></div>
<div class="col-md-4"><?= $form->field($model, 'state') ?></div>
<div class="col-md-4"><?= $form->field($model, 'zip') ?></div>
</div>
<div class="row">
<div class="col-md-6"><?= $form->field($model, 'email') ?></div>
<div class="col-md-6"><?= $form->field($model, 'phone') ?></div>
</div>
<?= $form->field($model, 'animals_we_accept')->textarea(['rows' => 3]) ?>
<?= $form->field($model, 'mission_statement')->textarea(['rows' => 4]) ?>
<hr>
<?= $form->field($model, 'header_html')->textarea(['rows' => 4]) ?>
<?= $form->field($model, 'body_html')->textarea(['rows' => 6]) ?>
<?= $form->field($model, 'footer_html')->textarea(['rows' => 4]) ?>
<div class="alert alert-warning">
<?= Yii::t('SpaceProfilesModule.base', 'Only HTML/CSS for profile regions is allowed. JavaScript and script-like content are removed automatically.') ?>
</div>
<hr>
<div class="help-block">
<?= Yii::t('SpaceProfilesModule.base', 'Allowed image formats: {formats}. Maximum file size: {size} MB.', [
'formats' => implode(', ', UploadStandards::imageExtensions()),
'size' => number_format(UploadStandards::IMAGE_MAX_BYTES / 1024 / 1024, 1),
]) ?>
</div>
<?php if ($profile && !empty($profile->icon_path)): ?>
<div class="form-group">
<label class="control-label"><?= Yii::t('SpaceProfilesModule.base', 'Current icon') ?></label>
<div><?= Html::img($profile->icon_path, ['style' => 'max-width:96px;max-height:96px;border-radius:8px;']) ?></div>
</div>
<?php endif; ?>
<?= $form->field($model, 'iconFile')->fileInput(['accept' => '.jpg,.jpeg,.png,.webp']) ?>
<?= $form->field($model, 'removeIcon')->checkbox() ?>
<?php if ($profile && !empty($profile->background_image_path)): ?>
<div class="form-group">
<label class="control-label"><?= Yii::t('SpaceProfilesModule.base', 'Current background image') ?></label>
<div><?= Html::img($profile->background_image_path, ['style' => 'max-width:320px;border-radius:8px;']) ?></div>
</div>
<?php endif; ?>
<?= $form->field($model, 'backgroundImageFile')->fileInput(['accept' => '.jpg,.jpeg,.png,.webp']) ?>
<?= $form->field($model, 'removeBackgroundImage')->checkbox() ?>
<?= Button::save()->submit() ?>
<?php ActiveForm::end(); ?>
</div>
</div>