From d297948fb9d0ef9f050274016832f16e6179e4cc Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 27 Jul 2026 13:24:42 +0100 Subject: [PATCH] UI and editing updates --- .../Console/InstallSaaslandContent.php | 121 ++++- Modules/Content/InstallSaaslandContent.php | 148 ------ .../Resources/views/admin/form.blade.php | 482 +++++++++++++----- .../views/documents/site-footer.blade.php | 4 +- .../views/documents/site-header.blade.php | 4 +- .../saasland/views/partials/footer.blade.php | 63 ++- .../saasland/views/partials/header.blade.php | 121 +++-- 7 files changed, 575 insertions(+), 368 deletions(-) delete mode 100644 Modules/Content/InstallSaaslandContent.php diff --git a/Modules/Content/Console/InstallSaaslandContent.php b/Modules/Content/Console/InstallSaaslandContent.php index 27aa628..fdc2462 100644 --- a/Modules/Content/Console/InstallSaaslandContent.php +++ b/Modules/Content/Console/InstallSaaslandContent.php @@ -50,9 +50,13 @@ final class InstallSaaslandContent extends Command ] ); - if ((int) $document->content_type_id !== (int) $type->id) { - $document->forceFill(['content_type_id' => $type->id])->save(); - } + $currentData = is_array($document->data) ? $document->data : []; + $mergedData = $this->mergeMissingDefaults($currentData, $definition['data']); + + $document->forceFill([ + 'content_type_id' => $type->id, + 'data' => $mergedData, + ])->save(); [$region, $sort] = $order[$key]; PageContent::query()->updateOrCreate( ['page_key' => 'home', 'region' => $region, 'sort_order' => $sort], @@ -65,6 +69,30 @@ final class InstallSaaslandContent extends Command return self::SUCCESS; } + private function mergeMissingDefaults(array $current, array $defaults): array + { + foreach ($defaults as $key => $defaultValue) { + if (! array_key_exists($key, $current)) { + $current[$key] = $defaultValue; + continue; + } + + if ( + is_array($defaultValue) + && is_array($current[$key]) + && ! array_is_list($defaultValue) + && ! array_is_list($current[$key]) + ) { + $current[$key] = $this->mergeMissingDefaults( + $current[$key], + $defaultValue + ); + } + } + + return $current; + } + private function definitions(): array { $field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra); @@ -84,11 +112,45 @@ final class InstallSaaslandContent extends Command 'site-header' => [ 'title' => 'Main website header', 'type' => $type('Site header', 'site-header', ['header'], [ - ...$section, $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]), $field('show_login', 'Show login', 'boolean', ['default' => true]), - $field('show_registration', 'Show registration', 'boolean', ['default' => true]), $field('login_label', 'Login label'), - $field('registration_label', 'Registration label'), $field('sticky', 'Sticky header', 'boolean', ['default' => true]), + $field('enabled', 'Show header', 'boolean', ['default' => true]), + $field('brand_text', 'Brand text'), + $field('brand_url', 'Brand URL', 'url'), + $field('logo_url', 'Logo URL', 'url'), + $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]), + $field('links', 'Navigation links', 'repeater', ['fields' => [ + $field('enabled', 'Show link', 'boolean', ['default' => true]), + $field('label', 'Label'), + $field('url', 'URL', 'url'), + ]]), + $field('show_dashboard', 'Show dashboard link', 'boolean', ['default' => true]), + $field('dashboard_label', 'Dashboard label'), + $field('show_user_name', 'Show signed-in user', 'boolean', ['default' => true]), + $field('show_login', 'Show login', 'boolean', ['default' => true]), + $field('login_label', 'Login label'), + $field('show_registration', 'Show registration', 'boolean', ['default' => true]), + $field('registration_label', 'Registration label'), + $field('show_mobile_menu', 'Show mobile menu', 'boolean', ['default' => true]), ]), - 'data' => ['enabled' => true, 'show_navigation' => true, 'show_login' => true, 'show_registration' => true, 'login_label' => 'Log in', 'registration_label' => 'Get started', 'sticky' => true], + 'data' => [ + 'enabled' => true, + 'brand_text' => config('app.name'), + 'brand_url' => '/', + 'logo_url' => '', + 'show_navigation' => true, + 'links' => [ + ['enabled' => true, 'label' => 'Home', 'url' => '/'], + ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], + ['enabled' => true, 'label' => 'News', 'url' => '/news'], + ], + 'show_dashboard' => true, + 'dashboard_label' => 'Dashboard', + 'show_user_name' => true, + 'show_login' => true, + 'login_label' => 'Log in', + 'show_registration' => true, + 'registration_label' => 'Get started', + 'show_mobile_menu' => true, + ], ], 'hero' => [ 'title' => 'Homepage hero', @@ -140,9 +202,48 @@ final class InstallSaaslandContent extends Command ], 'site-footer' => [ 'title' => 'Main website footer', - 'type' => $type('Site footer', 'site-footer', ['footer'], [...$section, $field('description', 'Description', 'textarea'), $field('show_navigation', 'Show footer navigation', 'boolean'), $field('show_account_links', 'Show account links', 'boolean', ['default' => true]), $field('show_social_links', 'Show social links', 'boolean', ['default' => true]), $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]), $field('copyright', 'Copyright text')]), - 'data' => ['enabled' => true, 'description' => 'One connected platform for modern teams.', 'show_navigation' => true, 'show_account_links' => true, 'show_social_links' => true, 'show_back_to_top' => true, 'copyright' => 'All rights reserved.'], + 'type' => $type('Site footer', 'site-footer', ['footer'], [ + $field('enabled', 'Show footer', 'boolean', ['default' => true]), + $field('brand_text', 'Brand text'), + $field('brand_url', 'Brand URL', 'url'), + $field('description', 'Description', 'textarea'), + $field('columns', 'Footer columns', 'repeater', ['fields' => [ + $field('enabled', 'Show column', 'boolean', ['default' => true]), + $field('heading', 'Heading'), + $field('links', 'Links', 'repeater', ['fields' => [ + $field('enabled', 'Show link', 'boolean', ['default' => true]), + $field('label', 'Label'), + $field('url', 'URL', 'url'), + ]]), + ]]), + $field('copyright', 'Copyright text'), + $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]), + $field('back_to_top_label', 'Back-to-top label'), + ]), + 'data' => [ + 'enabled' => true, + 'brand_text' => config('app.name'), + 'brand_url' => '/', + 'description' => 'Software and services built for modern organisations.', + 'columns' => [ + ['enabled' => true, 'heading' => 'Explore', 'links' => [ + ['enabled' => true, 'label' => 'Home', 'url' => '/'], + ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], + ['enabled' => true, 'label' => 'News', 'url' => '/news'], + ]], + ['enabled' => true, 'heading' => 'Account', 'links' => [ + ['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'], + ['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile'], + ]], + ['enabled' => true, 'heading' => 'Connect', 'links' => [ + ['enabled' => true, 'label' => 'Contact', 'url' => '/contact'], + ]], + ], + 'copyright' => '© '.date('Y').' '.config('app.name').'. All rights reserved.', + 'show_back_to_top' => true, + 'back_to_top_label' => 'Back to top ↑', + ], ], - ]; +]; } } diff --git a/Modules/Content/InstallSaaslandContent.php b/Modules/Content/InstallSaaslandContent.php deleted file mode 100644 index 27aa628..0000000 --- a/Modules/Content/InstallSaaslandContent.php +++ /dev/null @@ -1,148 +0,0 @@ - ! Schema::hasTable($table))); - - if ($missingTables !== []) { - $this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.'); - $this->components->info('Run php artisan migrate before installing the Saasland composition.'); - return self::FAILURE; - } - - DB::transaction(function (): void { - if ($this->option('force')) { - PageContent::query()->where('page_key', 'home')->delete(); - } - - $order = [ - 'site-header' => ['header', 10], 'hero' => ['body', 10], 'feature-grid' => ['body', 20], - 'product-feed' => ['body', 30], 'testimonial-feed' => ['body', 40], 'team-feed' => ['body', 50], - 'blog-feed' => ['body', 60], 'news-feed' => ['body', 70], 'faq-feed' => ['body', 80], - 'newsletter-form' => ['body', 90], 'call-to-action' => ['body', 100], 'site-footer' => ['footer', 10], - ]; - - foreach ($this->definitions() as $key => $definition) { - $type = ContentType::query()->updateOrCreate(['key' => $key], $definition['type']); - $document = ContentDocument::query()->firstOrCreate( - ['slug' => 'saasland-'.$key], - [ - 'content_type_id' => $type->id, - 'title' => $definition['title'], - 'data' => $definition['data'], - 'status' => 'published', - 'published_at' => now(), - ] - ); - - if ((int) $document->content_type_id !== (int) $type->id) { - $document->forceFill(['content_type_id' => $type->id])->save(); - } - [$region, $sort] = $order[$key]; - PageContent::query()->updateOrCreate( - ['page_key' => 'home', 'region' => $region, 'sort_order' => $sort], - ['content_document_id' => $document->id, 'is_enabled' => true] - ); - } - }); - - $this->components->info('Saasland content composition installed with editable schemas.'); - return self::SUCCESS; - } - - private function definitions(): array - { - $field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra); - $type = static fn (string $name, string $renderer, array $regions, array $schema): array => [ - 'name' => $name, 'renderer' => $renderer, 'allowed_regions' => $regions, - 'is_system' => true, 'is_active' => true, 'schema' => $schema, - ]; - $section = [$field('enabled', 'Show this section', 'boolean', ['default' => true])]; - $feed = array_merge($section, [ - $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), - $field('limit', 'Maximum items', 'number', ['min' => 1, 'max' => 50]), - $field('include_ids', 'Only show record IDs', 'csv', ['help' => 'Optional comma-separated IDs. Leave blank to use the latest records.']), - $field('exclude_ids', 'Exclude record IDs', 'csv', ['help' => 'Optional comma-separated IDs to hide from this section.']), - ]); - - return [ - 'site-header' => [ - 'title' => 'Main website header', - 'type' => $type('Site header', 'site-header', ['header'], [ - ...$section, $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]), $field('show_login', 'Show login', 'boolean', ['default' => true]), - $field('show_registration', 'Show registration', 'boolean', ['default' => true]), $field('login_label', 'Login label'), - $field('registration_label', 'Registration label'), $field('sticky', 'Sticky header', 'boolean', ['default' => true]), - ]), - 'data' => ['enabled' => true, 'show_navigation' => true, 'show_login' => true, 'show_registration' => true, 'login_label' => 'Log in', 'registration_label' => 'Get started', 'sticky' => true], - ], - 'hero' => [ - 'title' => 'Homepage hero', - 'type' => $type('Hero', 'hero', ['body'], [ - ...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), - $field('primary_label', 'Primary button label'), $field('primary_url', 'Primary button URL', 'url'), - $field('secondary_label', 'Secondary button label'), $field('secondary_url', 'Secondary button URL', 'url'), - $field('show_metrics', 'Show metrics panel', 'boolean', ['default' => true]), - $field('metrics', 'Metrics', 'repeater', ['fields' => [ - $field('enabled', 'Show item', 'boolean', ['default' => true]), $field('label', 'Label'), $field('value', 'Value'), $field('change', 'Change'), $field('height', 'Chart height (%)', 'number', ['min' => 0, 'max' => 100]), - ]]), - ]), - 'data' => ['enabled' => true, 'eyebrow' => 'A better way to run your business', 'heading' => 'Transform Your Business With Our SaaS Solution', 'description' => 'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.', 'primary_label' => 'Get started free', 'primary_url' => '/register', 'secondary_label' => 'Book demo', 'secondary_url' => '#contact', 'show_metrics' => true, 'metrics' => [ - ['enabled' => true, 'label' => 'Monthly revenue', 'value' => '£48,240', 'change' => '+18.4%', 'height' => 62], - ['enabled' => true, 'label' => 'Active users', 'value' => '12,842', 'change' => '+9.2%', 'height' => 78], - ['enabled' => true, 'label' => 'Conversion', 'value' => '7.8%', 'change' => '+2.1%', 'height' => 92], - ]], - ], - 'feature-grid' => [ - 'title' => 'Core features', - 'type' => $type('Feature grid', 'feature-grid', ['body'], [ - ...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), - $field('items', 'Features', 'repeater', ['fields' => [$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('title', 'Title'), $field('description', 'Description', 'textarea')]]), - ]), - 'data' => ['enabled' => true, 'eyebrow' => 'Powerful features', 'heading' => 'Everything your team needs to grow', 'description' => 'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.', 'items' => [ - ['enabled' => true, 'title' => 'Smart automation', 'description' => 'Remove repetitive work and delays.'], - ['enabled' => true, 'title' => 'Real-time analytics', 'description' => 'Turn activity into useful business insight.'], - ['enabled' => true, 'title' => 'Content operations', 'description' => 'Create, review and publish confidently.'], - ['enabled' => true, 'title' => 'Secure by design', 'description' => 'Protect users and data with dependable controls.'], - ['enabled' => true, 'title' => 'Flexible integrations', 'description' => 'Connect the tools your organisation already uses.'], - ['enabled' => true, 'title' => 'Modular architecture', 'description' => 'Activate only the capabilities each site needs.'], - ]], - ], - 'product-feed' => ['title' => 'Pricing plans', 'type' => $type('Product feed', 'product-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Simple pricing', 'heading' => 'Choose a plan that fits', 'description' => 'Plans are managed from Products & Pricing in the CMS.', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']], - 'testimonial-feed' => ['title' => 'Customer stories', 'type' => $type('Testimonial feed', 'testimonial-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Customer stories', 'heading' => 'Trusted by ambitious teams', 'description' => '', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']], - 'team-feed' => ['title' => 'Team', 'type' => $type('Team feed', 'team-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Our team', 'heading' => 'People behind the platform', 'description' => '', 'limit' => 8, 'include_ids' => '', 'exclude_ids' => '']], - 'blog-feed' => ['title' => 'Latest blog posts', 'type' => $type('Blog feed', 'blog-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Insights', 'heading' => 'Latest from the blog', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']], - 'news-feed' => ['title' => 'Latest news', 'type' => $type('News feed', 'news-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Newsroom', 'heading' => 'Latest news', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']], - 'faq-feed' => ['title' => 'Frequently asked questions', 'type' => $type('FAQ feed', 'faq-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'FAQ', 'heading' => 'Answers before you begin', 'description' => '', 'limit' => 12, 'include_ids' => '', 'exclude_ids' => '']], - 'newsletter-form' => [ - 'title' => 'Newsletter signup', - 'type' => $type('Newsletter form', 'newsletter-form', ['body'], [...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('placeholder', 'Email placeholder'), $field('button_label', 'Button label')]), - 'data' => ['enabled' => true, 'eyebrow' => 'Newsletter', 'heading' => 'Useful updates, no noise', 'description' => 'Receive product news, publishing guidance, and practical platform updates.', 'placeholder' => 'you@example.com', 'button_label' => 'Subscribe'], - ], - 'call-to-action' => [ - 'title' => 'Homepage final call to action', - 'type' => $type('Call to action', 'call-to-action', ['body'], [...$section, $field('anchor', 'Section anchor'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('button_label', 'Button label'), $field('button_url', 'Button URL', 'url')]), - 'data' => ['enabled' => true, 'anchor' => 'contact', 'heading' => 'Ready to transform your business?', 'description' => 'Start building a faster, more connected operation today.', 'button_label' => 'Get started free', 'button_url' => '/register'], - ], - 'site-footer' => [ - 'title' => 'Main website footer', - 'type' => $type('Site footer', 'site-footer', ['footer'], [...$section, $field('description', 'Description', 'textarea'), $field('show_navigation', 'Show footer navigation', 'boolean'), $field('show_account_links', 'Show account links', 'boolean', ['default' => true]), $field('show_social_links', 'Show social links', 'boolean', ['default' => true]), $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]), $field('copyright', 'Copyright text')]), - 'data' => ['enabled' => true, 'description' => 'One connected platform for modern teams.', 'show_navigation' => true, 'show_account_links' => true, 'show_social_links' => true, 'show_back_to_top' => true, 'copyright' => 'All rights reserved.'], - ], - ]; - } -} diff --git a/Modules/Content/Resources/views/admin/form.blade.php b/Modules/Content/Resources/views/admin/form.blade.php index 47ae90d..283fcfe 100644 --- a/Modules/Content/Resources/views/admin/form.blade.php +++ b/Modules/Content/Resources/views/admin/form.blade.php @@ -168,10 +168,16 @@ let data = parseObject(dataField.value); function parseObject(value) { - if (value && typeof value === 'object' && !Array.isArray(value)) return value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + try { const parsed = JSON.parse(value || '{}'); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {}; } catch (_) { return {}; } @@ -179,49 +185,166 @@ function normaliseSchema(raw) { if (typeof raw === 'string') { - try { raw = JSON.parse(raw); } catch (_) { return []; } + try { + raw = JSON.parse(raw); + } catch (_) { + return []; + } } - if (Array.isArray(raw)) return raw; - if (!raw || typeof raw !== 'object') return []; - if (Array.isArray(raw.fields)) return raw.fields; - if (Array.isArray(raw.schema)) return raw.schema; - return Object.values(raw).filter(field => field && typeof field === 'object' && field.key); + + if (Array.isArray(raw)) { + return raw; + } + + if (! raw || typeof raw !== 'object') { + return []; + } + + if (Array.isArray(raw.fields)) { + return raw.fields; + } + + if (Array.isArray(raw.schema)) { + return raw.schema; + } + + return Object.values(raw).filter( + field => field && typeof field === 'object' && field.key + ); } function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, character => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', })[character]); } - function get(path) { - return path.split('.').reduce((value, key) => value?.[key], data); + function pathParts(path) { + return String(path || '') + .split('.') + .filter(part => part !== ''); } - function set(path, value) { - const parts = path.split('.'); - let target = data; - parts.slice(0, -1).forEach(key => { - if (target[key] === undefined || target[key] === null) { - target[key] = /^\d+$/.test(parts[parts.indexOf(key) + 1] ?? '') ? [] : {}; + function getAtPath(path) { + return pathParts(path).reduce((value, part) => { + if (value === undefined || value === null) { + return undefined; } - target = target[key]; - }); - target[parts.at(-1)] = value; - sync(); + + return value[part]; + }, data); } - function sync() { - dataField.value = JSON.stringify(data, null, 2); + function setAtPath(path, value) { + const parts = pathParts(path); + + if (! parts.length) { + return; + } + + let target = data; + + parts.forEach((part, index) => { + const isLast = index === parts.length - 1; + + if (isLast) { + target[part] = value; + return; + } + + const nextPart = parts[index + 1]; + const shouldBeArray = /^\d+$/.test(nextPart); + + if ( + target[part] === undefined + || target[part] === null + || typeof target[part] !== 'object' + ) { + target[part] = shouldBeArray ? [] : {}; + } + + target = target[part]; + }); + + syncJson(); + } + + function fieldForPath(schema, path) { + const parts = pathParts(path); + let fields = schema; + let matched = null; + + for (const part of parts) { + if (/^\d+$/.test(part)) { + continue; + } + + matched = (fields || []).find(field => field.key === part) ?? null; + + if (! matched) { + return null; + } + + fields = matched.fields || []; + } + + return matched; } function defaultValue(field) { - if (Object.prototype.hasOwnProperty.call(field, 'default')) return field.default; - if (field.type === 'boolean') return false; - if (field.type === 'repeater') return []; + if (Object.prototype.hasOwnProperty.call(field, 'default')) { + return structuredClone(field.default); + } + + if (field.type === 'boolean') { + return false; + } + + if (field.type === 'repeater') { + return []; + } + return ''; } + function newRepeaterItem(field) { + const item = {}; + + (field.fields || []).forEach(child => { + item[child.key] = defaultValue(child); + }); + + return item; + } + + function hydrateMissingFields(fields, target) { + (fields || []).forEach(field => { + if (! Object.prototype.hasOwnProperty.call(target, field.key)) { + target[field.key] = defaultValue(field); + } + + if (field.type === 'repeater') { + if (! Array.isArray(target[field.key])) { + target[field.key] = []; + } + + target[field.key].forEach(item => { + if (item && typeof item === 'object' && ! Array.isArray(item)) { + hydrateMissingFields(field.fields || [], item); + } + }); + } + }); + } + + function syncJson() { + dataField.value = JSON.stringify(data, null, 2); + } + function fieldMarkup(field, value, path) { const help = field.help ? `

${escapeHtml(field.help)}

` @@ -229,16 +352,18 @@ if (field.type === 'boolean') { return ` - + ${help}`; } if (field.type === 'repeater') { - return repeaterMarkup(field, Array.isArray(value) ? value : [], path) + help; + return repeaterMarkup( + field, + Array.isArray(value) ? value : [], + path + ) + help; + } + + let inputType = 'text'; + + if (field.type === 'number') { + inputType = 'number'; + } else if (field.type === 'email') { + inputType = 'email'; + } else if (field.type === 'url') { + inputType = 'url'; } - const type = field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text'; return ` ${help}`; + + ${help}`; } function repeaterMarkup(field, items, path) { return ` -
-
+
+

${escapeHtml(field.label)}

-

Items can be reordered, hidden, or removed.

+

+ Add, edit, reorder, hide or remove items. +

- + +
+
- ${items.length ? items.map((item, index) => ` -
-
- Item ${index + 1} -
- - - + ${items.length + ? items.map((item, index) => ` +
+
+ Item ${index + 1} + +
+ + + + + +
-
-
- ${(field.fields || []).map(child => ` -
- ${fieldMarkup(child, item?.[child.key] ?? defaultValue(child), `${path}.${index}.${child.key}`)} -
`).join('')} -
-
`).join('') : '

No items. Select “Add item” to create one.

'} + +
+ ${(field.fields || []).map(child => ` +
+ ${fieldMarkup( + child, + item?.[child.key] ?? defaultValue(child), + `${path}.${index}.${child.key}` + )} +
+ `).join('')} +
+ + `).join('') + : ` +

+ No items. Select “Add item” to create one. +

+ `}
`; } - function render() { - const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]); - status.textContent = schema.length ? `${schema.length} editable field${schema.length === 1 ? '' : 's'}` : 'Schema unavailable'; + function currentSchema() { + return normaliseSchema(rawSchemas[String(typeSelect.value)]); + } - if (!schema.length) { + function render() { + const schema = currentSchema(); + + status.textContent = schema.length + ? `${schema.length} editable field${schema.length === 1 ? '' : 's'}` + : 'Schema unavailable'; + + if (! schema.length) { host.innerHTML = `
- This content type has no usable visual schema. Run php artisan content:install-saasland once to refresh schemas without replacing content. + This content type has no usable visual schema. + Run php artisan content:install-saasland once.
`; + return; } + hydrateMissingFields(schema, data); + syncJson(); + host.innerHTML = schema.map(field => fieldMarkup( field, - Object.prototype.hasOwnProperty.call(data, field.key) ? data[field.key] : defaultValue(field), + data[field.key], field.key )).join(''); - - bind(); } - function bind() { - host.querySelectorAll('[data-path]').forEach(element => { - const eventName = element.type === 'checkbox' ? 'change' : 'input'; - element.addEventListener(eventName, () => { - let value = element.type === 'checkbox' ? element.checked : element.value; - if (element.type === 'number') value = element.value === '' ? null : Number(element.value); - set(element.dataset.path, value); - }); - }); + host.addEventListener('input', event => { + const element = event.target.closest('[data-path]'); - host.querySelectorAll('[data-add]').forEach(button => { - button.addEventListener('click', () => { - const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]); - const field = schema.find(candidate => candidate.key === button.dataset.add); - const items = Array.isArray(get(button.dataset.add)) ? get(button.dataset.add) : []; - const newItem = {}; - (field?.fields || []).forEach(child => newItem[child.key] = defaultValue(child)); - items.push(newItem); - set(button.dataset.add, items); - render(); - }); - }); + if (! element || element.type === 'checkbox') { + return; + } - host.querySelectorAll('[data-remove]').forEach(button => { - button.addEventListener('click', () => { - if (!confirm('Remove this item?')) return; - const parts = button.dataset.remove.split('.'); - const index = Number(parts.pop()); - const path = parts.join('.'); - const items = Array.isArray(get(path)) ? get(path) : []; - items.splice(index, 1); - set(path, items); - render(); - }); - }); + let value = element.value; - host.querySelectorAll('[data-up], [data-down]').forEach(button => { - button.addEventListener('click', () => { - const raw = button.dataset.up ?? button.dataset.down; - const parts = raw.split('.'); - const index = Number(parts.pop()); - const path = parts.join('.'); - const items = Array.isArray(get(path)) ? get(path) : []; - const destination = button.dataset.up !== undefined ? index - 1 : index + 1; - if (destination < 0 || destination >= items.length) return; - [items[index], items[destination]] = [items[destination], items[index]]; - set(path, items); - render(); - }); - }); - } + if (element.type === 'number') { + value = element.value === '' ? null : Number(element.value); + } + + setAtPath(element.dataset.path, value); + }); + + host.addEventListener('change', event => { + const element = event.target.closest('[data-path]'); + + if (! element || element.type !== 'checkbox') { + return; + } + + setAtPath(element.dataset.path, element.checked); + }); + + host.addEventListener('click', event => { + const button = event.target.closest('button[data-action]'); + + if (! button) { + return; + } + + event.preventDefault(); + + const schema = currentSchema(); + const action = button.dataset.action; + const path = button.dataset.path; + const field = fieldForPath(schema, path); + const items = Array.isArray(getAtPath(path)) + ? [...getAtPath(path)] + : []; + + if (action === 'add') { + if (! field || field.type !== 'repeater') { + return; + } + + items.push(newRepeaterItem(field)); + setAtPath(path, items); + render(); + + return; + } + + const index = Number(button.dataset.index); + + if (! Number.isInteger(index) || index < 0 || index >= items.length) { + return; + } + + if (action === 'remove') { + if (! window.confirm('Remove this item?')) { + return; + } + + items.splice(index, 1); + } + + if (action === 'up' && index > 0) { + [items[index - 1], items[index]] = [items[index], items[index - 1]]; + } + + if (action === 'down' && index < items.length - 1) { + [items[index], items[index + 1]] = [items[index + 1], items[index]]; + } + + setAtPath(path, items); + render(); + }); typeSelect.addEventListener('change', () => { - const schema = normaliseSchema(rawSchemas[String(typeSelect.value)]); - const nextData = {}; - schema.forEach(field => nextData[field.key] = defaultValue(field)); - data = nextData; - sync(); + data = {}; + hydrateMissingFields(currentSchema(), data); + syncJson(); render(); }); dataField.addEventListener('change', () => { - try { - data = parseObject(dataField.value); - render(); - } catch (error) { - alert(`Invalid JSON: ${error.message}`); - } + data = parseObject(dataField.value); + render(); + }); + + form.addEventListener('submit', () => { + syncJson(); }); - form.addEventListener('submit', sync); render(); }); diff --git a/resources/themes/saasland/views/documents/site-footer.blade.php b/resources/themes/saasland/views/documents/site-footer.blade.php index 9a69066..9f989ac 100644 --- a/resources/themes/saasland/views/documents/site-footer.blade.php +++ b/resources/themes/saasland/views/documents/site-footer.blade.php @@ -1 +1,3 @@ -@include('stylist-theme-saasland::partials.footer') +@if (filter_var($data['enabled'] ?? true, FILTER_VALIDATE_BOOL)) + @include('stylist-theme-saasland::partials.footer', ['footerContent' => $data]) +@endif diff --git a/resources/themes/saasland/views/documents/site-header.blade.php b/resources/themes/saasland/views/documents/site-header.blade.php index 0812161..0c65272 100644 --- a/resources/themes/saasland/views/documents/site-header.blade.php +++ b/resources/themes/saasland/views/documents/site-header.blade.php @@ -1 +1,3 @@ -@include('stylist-theme-saasland::partials.header') +@if (filter_var($data['enabled'] ?? true, FILTER_VALIDATE_BOOL)) + @include('stylist-theme-saasland::partials.header', ['headerContent' => $data]) +@endif diff --git a/resources/themes/saasland/views/partials/footer.blade.php b/resources/themes/saasland/views/partials/footer.blade.php index 3d95dcc..f70ec6f 100644 --- a/resources/themes/saasland/views/partials/footer.blade.php +++ b/resources/themes/saasland/views/partials/footer.blade.php @@ -1,36 +1,43 @@ -