| Current Path : /var/www/html/reddsis/docs/module/ |
| Current File : /var/www/html/reddsis/docs/module/frontend-site.md |
# FrontendSite (CMS Builder) Module — Dokumentasi Penuh
## 1. Pengenalan
Module untuk membina website secara dynamic tanpa coding. Component-based architecture — admin pilih, susun, dan configure component untuk setiap page. Guna **DB sebagai source of truth**, **storage files sebagai performance layer**.
```
DB (Source of Truth) Storage (Cache/Performance)
┌─────────────────────┐ ┌──────────────────────────────────┐
│ frontend_sites │──sync──> │ LAYOUTS/SITE/{slug}.blade.php │
│ frontend_pages │──sync──> │ LAYOUTS/PAGE/{siteSlug}/{slug}.blade.php│
│ frontend_components │──sync──> │ PHP/{code}.blade.php │
│ frontend_page_comp │ │ ENTRY/PAGE/{slug}.blade.php │
│ │ │ site-meta.json (cache) │
└─────────────────────┘ └──────────────────────────────────┘
```
---
## 2. Database Schema
### `frontend_sites`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint (PK) | |
| name | string | Nama site |
| slug | string (unique) | URL slug |
| header_fk | bigint (FK) | FK → `frontend_components.id` (category: Header) |
| footer_fk | bigint (FK) | FK → `frontend_components.id` (category: Footer) |
| layout | longText | Site layout Blade |
| status | boolean | 1 = active, 0 = inactive |
| is_default | boolean | Site utama bila access `/` |
| sort_order | integer | Urutan display |
| timestamps | | |
### `frontend_components`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint (PK) | |
| name | string | Nama display |
| code | string (unique) | Identifier, jadi variable name dalam Blade (`$navbar`) |
| content | longText | HTML/Blade code |
| type | string | `PHP`, `HTML`, `JS`, `CSS` |
| category | string | `Header`, `Footer`, `Section`, `Content`, `Panel`, `Modal` |
| status | boolean | |
| timestamps | | |
### `frontend_pages`
| Column | Type | Description |
|--------|------|-------------|
| id | bigint (PK) | |
| site_fk | bigint (FK) | FK → `frontend_sites.id` |
| name | string | Nama page |
| slug | string | URL slug dalam site |
| layout | text (nullable) | Page layout Blade — override site layout kalau `use_custom_layout = true` |
| entry_script | text (nullable) | PHP init preprocessing (run first, set `$_shared`) |
| page_content | text (nullable) | Main page content (render utama, jadi `$__page__`) |
| use_custom_layout | boolean (default: false) | True = guna page layout sebagai override site layout |
| is_default | boolean | Page utama bila access `/{site}` |
| status | boolean | |
| sort_order | integer | Urutan display |
| timestamps | | |
**Unique:** `(site_fk, slug)`
### `frontend_page_components` (Pivot)
| Column | Type | Description |
|--------|------|-------------|
| id | bigint (PK) | |
| page_fk | bigint (FK) | FK → `frontend_pages.id` |
| component_fk | bigint (FK) | FK → `frontend_components.id` |
| sort | integer | Urutan component dalam page |
| params | json (nullable) | Override parameters |
| timestamps | | |
---
## 3. Architecture & Flow
### 3.1 Render Flow
```
Request: /my-site/about
│
FrontendSiteRenderController@resolve('my-site', 'about')
│
▼
PortalHandler::handle('my-site', 'about')
│
┌───────┴───────┐
▼ ▼
FrontendSite FrontendPage
(by slug) (by slug + site_fk)
│
▼
FrontendSiteCacheService::get('my-site')
→ baca site-meta.json / refresh
│
▼
1. Entry Script (init) → set $_shared vars, output = $__entry__
2. Render setiap component → jadi variable ($navbar, $hero, etc.)
3. Render Page Content → jadi $__page__ (main content)
4. Render header & footer components → $__header__, $__footer__
5. Tentukan layout:
├── use_custom_layout = true → guna page layout (override site layout)
├── use_custom_layout = false → guna site layout
└── tiada langsung → output $__page__ sahaja
│
▼
Return HTML → view('frontend.cms', ['html' => $html])
```
### 3.2 Storage Sync Flow
```
Admin CRUD (save/update/delete)
│
▼
BladeSyncService::syncAll()
│
├── syncComponents() → Storage::put("PHP/{code}.blade.php")
├── syncPageLayouts() → Storage::put("LAYOUTS/PAGE/{siteSlug}/{slug}.blade.php") ← per-site
├── syncSiteLayouts() → Storage::put("LAYOUTS/SITE/{slug}.blade.php")
├── syncEntries() → Storage::put("ENTRY/PAGE/{siteSlug}/{slug}.blade.php") ← entry_script
├── syncPageContent() → Storage::put("PAGE_CONTENT/{siteSlug}/{slug}.blade.php") ← page_content
└── CacheService::rebuild() → Storage::put('site-meta.json')
```
### 3.3 Variable Availability
Semua variable ini auto-available dalam component, page content, layout, dan entry script:
| Variable | Source | Description |
|----------|--------|-------------|
| `$siteSlug` | URL slug | Slug site semasa |
| `$siteName` | DB `sites.name` | Nama site |
| `$sitePages` | Cache | List semua pages dalam site |
| `$menus`, `$menuTree` | Role mappings | Menu navigation |
| `$__entry__` | Entry script output | Rendered HTML dari entry script |
| `$_shared` | stdClass object | Shared object — set property dalam entry script, guna di component/pageContent/layout |
| `$__header__` | Header component | Rendered header (navbar) — hanya dalam site/page layout |
| `$__page__` | Page Content | Rendered page content utama — hanya dalam site/page layout |
| `$__footer__` | Footer component | Rendered footer — hanya dalam site/page layout |
| `$navbar`, `$hero`, etc | Component code | Setiap component yang ditick di page |
Setiap component yang diassign ke page akan jadi variable dengan nama `$code` — contoh component dengan code `navbar` boleh diguna sebagai `$navbar` dalam page content atau layout.
**Auto-capture dari Entry Script:** Mulai v2, semua variable yang dideclare dengan `@php $var = value` dalam entry script **auto-carry** ke component, page content, dan layout. Tak perlu guna `$_shared` untuk variable ringkas:
```blade
{{-- Entry Script --}}
@php
$page_title = 'Selamat Datang';
$show_banner = true;
$items = App\Models\Backend\ContentArticle::take(3)->get();
@endphp
{{-- Page Content — $page_title, $show_banner, $items auto-available --}}
<h1>{{ $page_title }}</h1>
@if($show_banner)
<div class="banner">...</div>
@endif
@foreach($items as $item) ... @endforeach
```
**`$_shared`** — object khas yang guna pass-by-reference. Property yang diset dalam entry script akan nampak di component, page content, dan layout. Disarankan guna `$_shared` untuk data yang perlu diubah suai oleh multiple component:
```blade
{{-- Entry Script --}}
@php
$_shared->page_title = 'Selamat Datang';
$_shared->show_banner = true;
@endphp
{{-- Page Content --}}
<h1>{{ $_shared->page_title }}</h1>
@if($_shared->show_banner)
<div class="banner">...</div>
@endif
```
---
## 4. Models
### `FrontendSite`
- **Table:** `frontend_sites`
- **Attributes:** `#[Table]`, `#[Fillable]` (Laravel 13)
- **Relations:** `header()`, `footer()`, `pages()`, `defaultPage()`
- **Casts:** `status` (boolean), `is_default` (boolean), `sort_order` (integer)
### `FrontendComponent`
- **Table:** `frontend_components`
- **Attributes:** `#[Table]`, `#[Fillable]` (Laravel 13)
- **Relations:** `pages()` (BelongsToMany through pivot)
- **Casts:** `status` (boolean)
### `FrontendPage`
- **Table:** `frontend_pages`
- **Attributes:** `#[Table]`, `#[Fillable]` (Laravel 13)
- **Relations:** `site()`, `components()` (BelongsToMany through pivot)
- **Casts:** `is_default` (boolean), `status` (boolean)
### `FrontendPageComponent` (Pivot)
- **Table:** `frontend_page_components`
- **Attributes:** `#[Table]`, `#[Fillable]` (Laravel 13)
- **Relations:** `page()`, `component()`
- **Casts:** `sort` (integer), `params` (array)
---
## 5. Controllers
### `FrontendSiteRenderController` (Frontend)
Controller untuk render website di frontend.
| Method | Route | Description |
|--------|-------|-------------|
| `default()` | `GET /` | Render default site (is_default = true) |
| `resolve()` | `GET /{site}` / `GET /{site}/{slug}` / `GET /{slug}` | Render site/page atau fallback ke content-article |
| `home()` | `GET /{site}` | Render home page site |
| `page()` | `GET /{site}/{slug}` | Render specific page |
**Logic `resolve()`:**
1. **Single slug** — check cache untuk page dalam default site (`/$pageSlug`)
2. Jika jumpa → render page default site (`base-url/about`)
3. Jika tak jumpa → check cache untuk site slug (`/$siteSlug`)
4. Jika site wujud → render CMS
5. Jika tak wujud → fallback ke `HomeController@contentArticle`
### `FrontendSiteController` (Admin Backend)
CRUD untuk sites.
| Method | Route | Permission | Description |
|--------|-------|------------|-------------|
| `index()` | `GET /admin/frontend-site/sites` | `frontend-site.view` | List sites (sorted: default → active → inactive) |
| `create()` | `GET /admin/frontend-site/sites/create` | `frontend-site.create` | Form create site |
| `store()` | `POST /admin/frontend-site/sites` | `frontend-site.create` | Save site + sync storage |
| `edit()` | `GET /admin/frontend-site/sites/{id}/edit` | `frontend-site.update` | Form edit site |
| `update()` | `PUT /admin/frontend-site/sites/{id}` | `frontend-site.update` | Update site + sync |
| `destroy()` | `DELETE /admin/frontend-site/sites/{id}` | `frontend-site.delete` | Delete site + sync |
| `toggleStatus()` | `POST /admin/frontend-site/sites/{id}/toggle-status` | `frontend-site.update` | Toggle active/inactive via AJAX |
### `FrontendComponentController` (Admin Backend)
CRUD untuk components.
| Method | Route | Permission | Description |
|--------|-------|------------|-------------|
| `index()` | `GET /admin/frontend-site/components` | `frontend-site.view` | List components |
| `create()` | `GET /admin/frontend-site/components/create` | `frontend-site.create` | Form create component |
| `store()` | `POST /admin/frontend-site/components` | `frontend-site.create` | Save component + sync |
| `edit()` | `GET /admin/frontend-site/components/{id}/edit` | `frontend-site.update` | Form edit component |
| `update()` | `PUT /admin/frontend-site/components/{id}` | `frontend-site.update` | Update component + sync |
| `destroy()` | `DELETE /admin/frontend-site/components/{id}` | `frontend-site.delete` | Delete component + sync |
### `FrontendPageController` (Admin Backend)
CRUD untuk pages.
| Method | Route | Permission | Description |
|--------|-------|------------|-------------|
| `index()` | `GET /admin/frontend-site/pages` | `frontend-site.view` | List pages (sorted: default → active) |
| `create()` | `GET /admin/frontend-site/pages/create` | `frontend-site.create` | Form create page (with optional `site_id` pre-select) |
| `store()` | `POST /admin/frontend-site/pages` | `frontend-site.create` | Save/update page + assign components + sync. Support hidden `page_id` untuk update page yang dah create via modal Save. Validate Entry Script tak boleh ada `{!! $... !!}` syntax. |
| `edit()` | `GET /admin/frontend-site/pages/{id}/edit` | `frontend-site.update` | Form edit page |
| `update()` | `PUT /admin/frontend-site/pages/{id}` | `frontend-site.update` | Update page + reassign components + sync |
| `destroy()` | `DELETE /admin/frontend-site/pages/{id}` | `frontend-site.delete` | Delete page + detach components + sync |
---
## 6. Services
### `PortalHandler` — Core Render Engine
Service utama yang handle rendering CMS pages. **Hanya ada satu method utama** — semua content module dipapar sebagai component dalam page, bukan melalui dedicated handler.
**Method `handle(string $siteSlug, ?string $pageSlug = null): string`**
1. **Load meta** dari cache (`FrontendSiteCacheService::get()`)
2. **Load site & page** dari DB
3. **Sediakan variables** — `$siteSlug`, `$siteName`, `$sitePages`, `$_shared` (stdClass)
4. **Execute entry script** — render PHP preprocessing, auto-capture `@php $var = value`, output simpan sebagai `$__entry__`
5. **Render components** — loop page components, render guna `Blade::render()`
6. **Render Page Content** — render `PAGE_CONTENT/{siteSlug}/{slug}.blade.php` sebagai `$pageHtml`
7. **Render header & footer** — dari site FK ke component
8. **Tentukan layout**:
- `use_custom_layout = true` → render page layout sebagai override site layout
- `use_custom_layout = false` → render site layout
- tiada langsung → output page content sahaja
> **ContentArticle / Gallery detail** tidak di-render oleh PortalHandler secara berasingan. Ia dimuat melalui page component guna `request()->query('id')` — lihat `docs/FRONTEND-COMPONENT-GUIDE.md` §4.2.
### `BladeSyncService` — Storage Sync
Sync data dari DB ke storage files.
**Method `syncAll()`:**
1. `syncComponents()` — write semua component content ke `PHP/{code}.blade.php`
2. `syncPageLayouts()` — write page layouts ke `LAYOUTS/PAGE/{siteSlug}/{slug}.blade.php`
3. `syncSiteLayouts()` — write site layouts ke `LAYOUTS/SITE/{slug}.blade.php`
4. `syncEntries()` — write entry scripts (`entry_script`) ke `ENTRY/PAGE/{siteSlug}/{slug}.blade.php`
5. `syncPageContent()` — write page content (`page_content`) ke `PAGE_CONTENT/{siteSlug}/{slug}.blade.php`
6. Rebuild cache via `FrontendSiteCacheService::rebuild()`
### `FrontendSiteCacheService` — Cache Management
Cache structure `site-meta.json` untuk performance.
**Structure `site-meta.json`:**
```json
{
"my-site": {
"id": 1,
"name": "My Modern Site",
"slug": "my-site",
"is_default": true,
"header_code": "navbar",
"footer_code": "footer",
"pages": {
"home": {
"id": 1,
"name": "Home",
"is_default": true,
"components": ["hero", "features"]
},
"about": {
"id": 2,
"name": "About",
"is_default": false,
"components": ["about_content"]
}
}
}
}
```
---
## 7. Routes
### Admin Routes (dalam `prefix('admin')->middleware('auth:admin')`)
**Prefix:** `/admin/frontend-site`
**Route name:** `frontend-site.*`
#### Sites (7 routes)
| Method | URI | Permission | Name |
|--------|-----|------------|------|
| GET | `/admin/frontend-site/sites` | `frontend-site.view` | `frontend-site.sites.index` |
| GET | `/admin/frontend-site/sites/create` | `frontend-site.create` | `frontend-site.sites.create` |
| POST | `/admin/frontend-site/sites` | `frontend-site.create` | `frontend-site.sites.store` |
| GET | `/admin/frontend-site/sites/{id}/edit` | `frontend-site.update` | `frontend-site.sites.edit` |
| PUT | `/admin/frontend-site/sites/{id}` | `frontend-site.update` | `frontend-site.sites.update` |
| DELETE | `/admin/frontend-site/sites/{id}` | `frontend-site.delete` | `frontend-site.sites.destroy` |
| POST | `/admin/frontend-site/sites/{id}/toggle-status` | `frontend-site.update` | `frontend-site.sites.toggle-status` |
#### Components (6 routes)
| Method | URI | Permission | Name |
|--------|-----|------------|------|
| GET | `/admin/frontend-site/components` | `frontend-site.view` | `frontend-site.components.index` |
| GET | `/admin/frontend-site/components/create` | `frontend-site.create` | `frontend-site.components.create` |
| POST | `/admin/frontend-site/components` | `frontend-site.create` | `frontend-site.components.store` |
| GET | `/admin/frontend-site/components/{id}/edit` | `frontend-site.update` | `frontend-site.components.edit` |
| PUT | `/admin/frontend-site/components/{id}` | `frontend-site.update` | `frontend-site.components.update` |
| DELETE | `/admin/frontend-site/components/{id}` | `frontend-site.delete` | `frontend-site.components.destroy` |
#### Pages (6 routes)
| Method | URI | Permission | Name |
|--------|-----|------------|------|
| GET | `/admin/frontend-site/pages` | `frontend-site.view` | `frontend-site.pages.index` |
| GET | `/admin/frontend-site/pages/create` | `frontend-site.create` | `frontend-site.pages.create` |
| POST | `/admin/frontend-site/pages` | `frontend-site.create` | `frontend-site.pages.store` |
| GET | `/admin/frontend-site/pages/{id}/edit` | `frontend-site.update` | `frontend-site.pages.edit` |
| PUT | `/admin/frontend-site/pages/{id}` | `frontend-site.update` | `frontend-site.pages.update` |
| DELETE | `/admin/frontend-site/pages/{id}` | `frontend-site.delete` | `frontend-site.pages.destroy` |
| POST | `/admin/frontend-site/pages/reorder` | `frontend-site.update` | `frontend-site.pages.reorder` |
| GET | `/admin/frontend-site/pages/layout-builder` | `frontend-site.create` | `frontend-site.pages.layout-builder` |
### Frontend Render Routes
| Method | URI | Controller | Name | Notes |
|--------|-----|------------|------|-------|
| GET | `/` | `FrontendSiteRenderController@default` | `home` | Default site home page |
| GET | `/{slug}` | `FrontendSiteRenderController@resolve` | `frontend-site.home` | Default site page **atau** site home **atau** content article |
| GET | `/{site}/{slug}` | `FrontendSiteRenderController@resolve` | `frontend-site.page` | Non-default site page (e.g. `/site-2/page-2`) |
> **Default site:** Page slug terus di root URL: `base-url/page-slug`. Tak perlu suplai site slug.
> **Other sites:** Guna format `base-url/site-slug/page-slug`.
---
## 8. Views Structure
```
resources/views/backend/module/frontendSite/
├── index.blade.php ← Dashboard
├── site/
│ ├── index.blade.php ← List sites
│ ├── create.blade.php ← Create site form
│ └── edit.blade.php ← Edit site form
├── component/
│ ├── index.blade.php ← List components
│ ├── create.blade.php ← Create component form
│ └── edit.blade.php ← Edit component form
└── page/
├── index.blade.php ← List pages
├── create.blade.php ← Create page form
└── edit.blade.php ← Edit page form
```
### Frontend Layout
```
resources/views/frontend/cms.blade.php ← {!! $html !!}
```
---
## 9. Page Editor Features (Monaco)
Halaman create/edit page menggunakan **Monaco Editor** (VS Code engine) untuk Entry Script, Page Content, dan Layout.
### 9.1 Pre-editing Validation (Name & Slug)
Sebelum modal editor boleh dibuka atau disave:
- Editor trigger boxes **disable** (greyed out) kalau Name atau Slug kosong.
- Butang Save dalam modal **disable** sehingga Name & Slug diisi.
- Kalau user cuba klik trigger atau drop component tanpa isi Name/Slug:
- Field Name & Slug dapat border merah + shake animation.
- Toast merah: *"Please fill in Name and Slug before editing code."*
- Error highlight hilang automatik bila user mula taip.
### 9.2 AJAX Save Behavior
- AJAX save hanya hantar **field yang sedang diedit** (Entry Script / Page Content / Layout).
- AJAX modal save **tidak update/create** `name` & `slug`; hanya update kod.
- Create mode guna hidden field `page_id` untuk koordinasi antara modal Save dan main Save:
- First modal Save create page dan return `page_id`.
- Main Save kemudian update page yang sama, elak error *"The slug has already been taken"*.
### 9.3 Entry Script Restriction
- Entry Script **dilarang** mengandungi `{!! $... !!}` syntax.
- Output component (`{!! $code !!}`) mesti letak dalam **Page Content** atau **Layout**.
- Entry Script hanya untuk `@php` logic / preprocessing.
### 9.4 Component Selection & Variable Insertion
Admin page (`page/create.blade.php` & `page/edit.blade.php`) menyediakan tiga cara untuk insert component variables:
| Method | Cara | Kelebihan |
|--------|------|-----------|
| **Checkbox** | Tick component → tulis `@{!! $code !!}` manual dalam editor | Biasalah |
| **Drag & drop** | Drag label komponen (≡) terus ke editor | Insert auto, checkbox auto tick |
| **Autocomplete** | Taip `$` dalam editor → pilih dari suggestion dropdown | Cepat, tak perlu tinggalkan editor |
### 9.5 Auto-sync Checkbox
- **Drag component** → checkbox auto tick
- **Pilih suggestion autocomplete** → checkbox auto tick
- **Padam `{!! $code !!}` dari editor** → checkbox auto untick (lepas 400ms)
- Sync berlaku merentas semua editor (Entry, Page Content, Layout)
### 9.6 Drop Handler
Drag & drop guna Monaco API `executeEdits()` — bukan native browser drop. Jadi `$` dalam variable tak akan jadi snippet placeholder.
---
### `_page_header` Partial
`resources/views/backend/layouts/partials/_page_header.blade.php`
Menyokong parameter tambahan untuk route:
| Variable | Type | Example | Description |
|----------|------|---------|-------------|
| `$pageTitle` | string | `'Create Page'` | Title halaman |
| `$indexRoute` | string | `'frontend-site.pages.index'` | Route name untuk breadcrumb |
| `$indexLabel` | string | `'Pages'` | Label breadcrumb |
| `$indexRouteParams` | array | `['site_id' => 5]` | Optional query params untuk route |
**Usage:**
```php
@php
$pageTitle = 'Edit Page';
$indexRoute = 'frontend-site.pages.index';
$indexLabel = 'Pages';
$indexRouteParams = ['site_id' => $page->site_fk];
@endphp
@include('backend.layouts.partials._page_header')
```
### Cancel Button
Page create/edit views redirect back to filtered list:
```blade
<a href="{{ route('frontend-site.pages.index', ['site_id' => $page->site_fk]) }}">Cancel</a>
```
---
## 10. Toggle System
### Style 1 — Inline Toggle (Index Tables)
Guna Alpine.js `x-data` dengan `$refs` untuk sync hidden checkbox:
```blade
<div x-data="{ on: {{ $page->status ? 'true' : 'false' }} }" class="flex items-center gap-2">
<button type="button" @click="on = !on; fetch('{{ route('...toggle-status', $page->id) }}', { method: 'POST', headers: { 'X-CSRF-TOKEN': '...' } })"
:class="on ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-600'"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200">
<span :class="on ? 'translate-x-full' : ''"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200"></span>
</button>
<span class="text-xs font-medium" :class="on ? 'text-success-600' : 'text-gray-400'" x-text="on ? 'Active' : 'Inactive'"></span>
</div>
```
### Style 2 — Form Toggle (Create/Edit Forms)
Guna Alpine.js `$refs.checkbox` untuk sync hidden input:
```blade
<div x-data="{ toggle: {{ old('status', true) ? 'true' : 'false' }} }" class="flex items-center gap-3">
<span class="text-sm font-medium">Status</span>
<button type="button" @click="toggle = !toggle; $refs.checkbox.checked = toggle"
:class="toggle ? 'bg-brand-500' : 'bg-gray-300 dark:bg-gray-600'"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200">
<span :class="toggle ? 'translate-x-full' : ''"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200"></span>
</button>
<span x-text="toggle ? 'Active' : 'Inactive'"></span>
<input type="checkbox" name="status" value="1" x-ref="checkbox" {{ old('status', true) ? 'checked' : '' }} class="hidden">
</div>
```
### Default Site/Page Toggle (Index Tables)
Guna radio button dengan AJAX. Setiap site/page ada radio button. Klik → set sebagai default, yang lain auto unset.
```blade
<input type="radio" name="default_site" value="{{ $site->id }}"
{{ $site->is_default ? 'checked' : '' }}
onchange="if(this.checked) fetch('{{ route('frontend-site.sites.toggle-default', $site->id) }}', { method: 'POST', headers: { 'X-CSRF-TOKEN': '...' } }).then(r => { if(r.ok) window.location.reload(); })"
class="h-4 w-4 border-gray-300 text-brand-500 cursor-pointer">
```
### Controller Methods
```php
// Toggle status (Active/Inactive)
public function toggleStatus($id)
{
$model = Model::findOrFail($id);
$model->update(['status' => !$model->status]);
app(BladeSyncService::class)->syncAll();
return response()->json(['status' => $model->fresh()->status]);
}
// Toggle default (set as default, unset others)
public function toggleDefault($id)
{
$item = Model::findOrFail($id);
Model::where('is_default', true)->update(['is_default' => false]);
$item->update(['is_default' => true]);
app(BladeSyncService::class)->syncAll();
flash()->success("'{$item->name}' is now the default");
return response()->json(['is_default' => true]);
}
```
### Routes
```php
Route::post('/sites/{site}/toggle-status', 'toggleStatus')->name('sites.toggle-status');
Route::post('/sites/{site}/toggle-default', 'toggleDefault')->name('sites.toggle-default');
Route::post('/pages/{page}/toggle-status', 'toggleStatus')->name('pages.toggle-status');
Route::post('/pages/{page}/toggle-default', 'toggleDefault')->name('pages.toggle-default');
```
### Auto-unset Default Logic
Bila set satu page sebagai default, page lain dalam **site yang sama** auto di-unset:
```php
// Store
if ($data['is_default']) {
FrontendPage::where('site_fk', $data['site_fk'])->where('is_default', true)->update(['is_default' => false]);
}
// Update
if ($data['is_default']) {
FrontendPage::where('site_fk', $data['site_fk'])->where('id', '!=', $id)->where('is_default', true)->update(['is_default' => false]);
}
```
---
## 11. Storage Structure (Auto-Generated)
```
storage/app/private/
├── site-meta.json ← Cache semua metadata (json)
├── PHP/
│ ├── navbar.blade.php ← Component content (code → file)
│ ├── hero_banner.blade.php
│ └── footer.blade.php
├── LAYOUTS/SITE/
│ ├── my-site.blade.php ← Site layout (full HTML)
│ └── module-site.blade.php
├── LAYOUTS/PAGE/
│ ├── my-site/ ← Per-site page layout directory
│ │ ├── home.blade.php
│ │ ├── about.blade.php
│ │ └── contact.blade.php
│ └── module-site/
│ └── home.blade.php
├── ENTRY/PAGE/
│ ├── my-site/
│ │ └── home.blade.php ← Entry scripts (entry_script)
│ └── module-site/
│ └── home.blade.php
└── PAGE_CONTENT/
└── my-site/
└── home.blade.php ← Page Content (page_content)
```
---
## 12. Example Usage
### Admin Creates:
```
Site: "My Modern Site" (slug: my-site)
├── Header → component: "Navbar" (code: navbar)
├── Footer → component: "Footer" (code: footer)
├── Layout → {!! $__header__ !!} {!! $__page__ !!} {!! $__footer__ !!}
│
├── Page: "Home" (slug: home, default: true)
│ ├── Entry Script: @php $_shared->title = 'Welcome' @endphp
│ ├── Page Content: <h1>{{ $_shared->title }}</h1> {!! $hero !!} {!! $features !!}
│ ├── Components: Hero (code: hero), Features (code: features)
│ └── Layout: (optional — override site layout)
│
├── Page: "About" (slug: about)
│ ├── Page Content: {!! $about_content !!}
│ └── Components: About Content (code: about_content)
│
└── Page: "Contact" (slug: contact)
├── Page Content: {!! $contact_form !!}
└── Components: Contact Form (code: contact_form)
```
### Frontend Renders:
```
/ → Default site's default page
/default-site/about → Default site "About" page (via /about also works)
/my-site → "my-site" home page
/my-site/about → "my-site" About page
/my-site/article?id=pelancaran-2026 → Article detail via component query param
/module-site → Module home (8 module components: articles, galleries, etc.)
```
### Module Site Renders:
```
/module-site → Home page (8 module components rendering DB content)
├── module_sliders → ContentSlider::where('slider_status', 'ACTIVE')
├── module_articles → ContentArticle::with('translations')...ACTIVE
├── module_galleries → ContentPhotoGallery::with('translations')...ACTIVE
├── module_videos → ContentVideo::where('video_status', 'ACTIVE')
├── module_downloads → ContentDownload::where('download_main','1')...ACTIVE
├── module_applications → ContentApplication::with('translations')...ACTIVE
├── module_images → ContentImage::where('image_main','1')...ACTIVE
└── module_footer → Static footer HTML
```
Setiap module component guna pattern `str_starts_with($val, 'http') ? $val : Storage::url($val)` untuk menyokong kedua-dua URL external (picsum.photos) dan local storage. Gambar external tidak di-serve melalui `Storage::url()`.
**ContentArticle / Gallery detail** tidak ada dedicated URL. Guna page component dengan query param:
```
/{siteSlug}/{pageSlug}?id={article_code}
```
Contoh: `/module-site/article?id=pelancaran-2026` — page component query `request()->query('id')` untuk filter single item.
---
## 13. Component Variable System
Component yang diassign ke page akan **auto menjadi variable** dalam layout berdasarkan `code`:
| Component Code | Variable Dalam Layout |
|----------------|---------------------|
| `navbar` | `{!! $navbar !!}` |
| `hero` | `{!! $hero !!}` |
| `footer` | `{!! $footer !!}` |
| `features` | `{!! $features !!}` |
| `about_content` | `{!! $about_content !!}` |
| `contact_form` | `{!! $contact_form !!}` |
### Dalam Site Layout:
```html
{!! $__header__ !!} ← Header component (dari FK header_fk)
{!! $__page__ !!} ← Page content (rendered page)
{!! $__footer__ !!} ← Footer component (dari FK footer_fk)
```
### Dalam Component Code:
```blade
<nav>
<a href="/{{ $siteSlug }}">{{ $siteName }}</a>
@foreach($sitePages as $page)
<a href="{{ $page['url'] }}">{{ $page['name'] }}</a>
@endforeach
</nav>
```
---
## 14. Sample Code
### 12.1 Component — Navbar (`code: navbar`)
Disimpan dalam DB `frontend_components.content`, di-sync ke `storage/app/private/PHP/navbar.blade.php`.
```blade
<nav style="background:#0f172a;padding:0 2rem;display:flex;align-items:center;justify-content:space-between;height:70px;font-family:sans-serif;position:sticky;top:0;z-index:50;">
<a href="/{{ $siteSlug }}" style="color:white;font-size:1.25rem;font-weight:700;text-decoration:none;">{{ $siteName }}</a>
<div style="display:flex;gap:0.5rem;align-items:center;">
@foreach($sitePages as $page)
<a href="{{ $page['url'] }}" style="color:#cbd5e1;text-decoration:none;font-size:0.9rem;padding:0.5rem 1rem;border-radius:0.5rem;transition:0.2s;">
{{ $page['name'] }}
</a>
@endforeach
</div>
</nav>
```
**Available variables:** `$siteSlug`, `$siteName`, `$sitePages` (dari PortalHandler), plus semua component code lain.
### 12.2 Component — Hero Banner (`code: hero`)
```blade
<section style="background:linear-gradient(135deg,#0f172a 0%,#1e293b 50%,#0f172a 100%);padding:6rem 2rem;text-align:center;font-family:sans-serif;">
<div style="max-width:720px;margin:0 auto;">
<span style="display:inline-block;background:rgba(59,130,246,0.15);color:#60a5fa;padding:0.35rem 1rem;border-radius:999px;font-size:0.8rem;font-weight:600;margin-bottom:1.5rem;">
✓ Modern CMS Solution
</span>
<h1 style="color:white;font-size:3rem;font-weight:700;margin:0 0 1rem;">Build Beautiful Websites<br>Without Coding</h1>
<p style="color:#94a3b8;font-size:1.15rem;max-width:560px;margin:0 auto 2rem;">
Create stunning pages with drag-and-drop components.
</p>
<a href="/{{ $siteSlug }}/contact" style="display:inline-block;background:#3b82f6;color:white;padding:0.85rem 2rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
Get Started
</a>
</div>
</section>
```
### 12.3 Site Layout (di `frontend_sites.layout`)
Disimpan dalam DB, di-sync ke `storage/app/private/LAYOUTS/SITE/{slug}.blade.php`.
```blade
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{!! $siteName !!}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 100%; min-height: 100vh; background: #fff; font-family: sans-serif; }
</style>
</head>
<body>
{!! $__header__ !!}
{!! $__page__ !!}
{!! $__footer__ !!}
</body>
</html>
```
**Available variables:** `$siteSlug`, `$siteName`, `$sitePages`, `$__header__`, `$__page__`, `$__footer__`, plus semua component code.
### 12.4 Page Layout (Override Site Layout) — di `frontend_pages.layout`
Disimpan dalam DB, di-sync ke `storage/app/private/LAYOUTS/PAGE/{siteSlug}/{slug}.blade.php`.
Hanya diguna jika `use_custom_layout = true` (toggle ON). Layout ni **menggantikan** site layout sepenuhnya. Boleh guna `$__header__`, `$__page__`, `$__footer__`:
```blade
<!DOCTYPE html>
<html>
<head><title>{{ $_shared->page_title ?? $siteName }}</title></head>
<body>
{!! $__header__ !!}
{!! $__page__ !!}
{!! $__footer__ !!}
</body>
</html>
```
**Available variables:** Semua component variable, `$__header__`, `$__page__`, `$__footer__`, `$_shared`, `$__entry__`
### 12.5 Entry Script (PHP Init Preprocessing)
Disimpan dalam `frontend_pages.entry_script`, di-sync ke `storage/app/private/ENTRY/PAGE/{slug}.blade.php`.
Entry script berfungsi sebagai **init** — run paling awal. Ada dua cara untuk kongsi data ke component/page content/layout:
**Cara 1 — Auto-capture (v2):** `@php $var = value` terus auto-carried forward:
```blade
@php
$items = App\Models\Backend\ContentArticle::take(5)->get();
$page_title = 'Latest Articles';
@endphp
```
**Cara 2 — `$_shared` (object, guna pass-by-reference):**
```blade
@php
use App\Models\Backend\Visit;
$_shared->total_visits = Visit::count();
$_shared->today_visits = Visit::whereDate('created_at', today())->count();
$_shared->page_title = 'Visitor Statistics';
@endphp
```
Output entry script boleh diguna sebagai `{{ $__entry__ }}` atau `{!! $__entry__ !!}` dalam component, page content, atau layout.
**Nota:** Entry script output jadi fallback page content jika page_content kosong.
### 12.6 Page Content (Render Utama)
Disimpan dalam `frontend_pages.page_content`, di-sync ke `storage/app/private/PAGE_CONTENT/{siteSlug}/{slug}.blade.php`.
Ini adalah content utama page. Boleh guna semua component variables (`$hero`, `$features`), auto-captured variables dari entry script, dan shared variables (`$_shared`):
```blade
<h1>{{ $_shared->page_title }}</h1>
<p>Total visits: {{ $_shared->total_visits }}</p>
<p>Today: {{ $_shared->today_visits }}</p>
{!! $hero !!}
{!! $features !!}
```
### 12.7 Frontend Wrapper View
`resources/views/frontend/cms.blade.php` — satu-satunya file yang perlu ada:
```blade
{!! $html !!}
```
Ianya hanya me-render output HTML dari `PortalHandler`.
### 12.8 PortalHandler — Core Render Logic
```php
// Pseudocode ringkas
public function handle(string $siteSlug, ?string $pageSlug = null): string
{
// 1. Load dari cache
$meta = $this->cache->get($siteSlug);
// 2. Load site & page dari DB
$site = FrontendSite::where('slug', $siteSlug)->firstOrFail();
$page = $pageSlug
? FrontendPage::where('site_fk', $site->id)->where('slug', $pageSlug)->firstOrFail()
: $site->defaultPage()->firstOrFail();
// 3. Sediakan variables + shared object
$shared = new \stdClass();
$vars = ['siteSlug' => $siteSlug, 'siteName' => $meta['name'], 'sitePages' => $pageList, '_shared' => $shared];
// 4. Entry script — init preprocessing (run first)
// Auto-capture @php $var = value → merge ke $vars
$entryBlade = Storage::get("ENTRY/PAGE/{$siteSlug}/{$page->slug}.blade.php");
if ($entryBlade) {
$compiled = Blade::compileString($entryBlade);
$__tmp = tempnam(sys_get_temp_dir(), 'entry_') . '.php';
file_put_contents($__tmp, $compiled);
$__result = (function() use ($__tmp, $vars) {
$__orig = array_keys($vars);
extract($vars); ob_start(); include $__tmp; $output = ob_get_clean();
$all = get_defined_vars();
foreach ($all as $k => $v) {
if (!in_array($k, $__orig, true) && !in_array($k, ['__orig','vars','output','all','k','v','__tmp'], true))
$vars[$k] = $v;
}
return ['output' => $output, 'vars' => $vars];
})();
$vars = $__result['vars'];
$vars['__entry__'] = $__result['output'];
unlink($__tmp);
}
// 5. Render setiap component → jadi variable ($hero, $features, etc)
foreach ($page->components as $comp) {
$blade = Storage::get("PHP/{$comp->code}.blade.php");
$vars[$comp->code] = $blade ? Blade::render($blade, $vars) : '';
}
// 6. Render Page Content sebagai content utama
$pageContentBlade = Storage::get("PAGE_CONTENT/{$siteSlug}/{$page->slug}.blade.php");
$pageHtml = $pageContentBlade
? Blade::render($pageContentBlade, $vars)
: $vars['__entry__'];
// 7. Render header & footer
$header = Blade::render(Storage::get("PHP/{$site->header->code}.blade.php"), $vars);
$footer = Blade::render(Storage::get("PHP/{$site->footer->code}.blade.php"), $vars);
$layoutVars = array_merge($vars, [
'__header__' => $header, '__page__' => $pageHtml, '__footer__' => $footer
]);
// 8. Tentukan layout
if ($page->use_custom_layout) {
$pageLayout = Storage::get("LAYOUTS/PAGE/{$siteSlug}/{$page->slug}.blade.php");
return $pageLayout ? Blade::render($pageLayout, $layoutVars) : Blade::render($siteLayout, $layoutVars);
}
$siteLayout = Storage::get("LAYOUTS/SITE/{$site->slug}.blade.php");
return $siteLayout ? Blade::render($siteLayout, $layoutVars) : $pageHtml;
}
```
### 12.9 Controller CRUD — Example (FrontendSiteController)
```php
// Store method
public function store(FrontendSiteRequest $request)
{
$data = $request->validated();
$data['status'] = $request->boolean('status');
$data['is_default'] = $request->boolean('is_default');
FrontendSite::create($data);
app(BladeSyncService::class)->syncAll(); // Auto sync ke storage
flash()->success('Site created successfully!');
return redirect()->route('frontend-site.sites.index');
}
```
### 12.10 Toggle Status — AJAX
`FrontendSiteController@toggleStatus` dipanggil via fetch dari index table:
```javascript
// Dalam site/index.blade.php — Alpine.js
fetch('{{ route('frontend-site.sites.toggle-status', $site->id) }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Content-Type': 'application/json' }
})
```
```php
// Controller
public function toggleStatus($id)
{
$site = FrontendSite::findOrFail($id);
$site->update(['status' => !$site->status]);
app(BladeSyncService::class)->syncAll();
return response()->json(['status' => $site->fresh()->status]);
}
```
---
## 15. Permissions
| Permission | Description |
|------------|-------------|
| `frontend-site.view` | View sites, components, pages list |
| `frontend-site.create` | Create sites, components, pages |
| `frontend-site.update` | Edit sites, components, pages |
| `frontend-site.delete` | Delete sites, components, pages |
---
## 16. Seeder — `FrontendSiteDemoSeeder`
Mencipta 2 demo sites dengan component berbeza:
| Site | Slug | Type | Pages | Description |
|------|------|------|-------|-------------|
| My Modern Site | `my-site` | Static landing | 3 (Home, About, Contact) | Fixed HTML components (navbar, hero, features, footer) |
| Module Content Site | `module-site` | Module content | 1 (Home) | 8 PHP components querying DB (articles, galleries, videos, etc.) |
**Static site components:** `navbar`, `hero_banner`, `features_grid`, `about_content`, `contact_form`, `footer`
**Module site components:** `module_articles`, `module_galleries`, `module_videos`, `module_downloads`, `module_applications`, `module_images`, `module_sliders`, `module_footer`
### Prasyarat
```bash
# Mula-mula seed data content module (5 items setiap module)
php artisan db:seed --class=ContentModuleSeeder
# Kemudian seed sites + components + sync storage
php artisan db:seed --class=FrontendSiteDemoSeeder
```
> **Nota:** `ContentModuleSeeder` mesti dijalankan SEBELUM `FrontendSiteDemoSeeder` kerana module site components query data dari content module tables.
Buka:
- `http://127.0.0.1:8000/my-site` — Static landing page
- `http://127.0.0.1:8000/module-site` — Module content site
---
## 17. File Index
```
app/
├── Http/
│ ├── Controllers/
│ │ ├── FrontendSiteRenderController.php ← Frontend render
│ │ └── Backend/
│ │ ├── FrontendSiteController.php ← Site CRUD
│ │ ├── FrontendPageController.php ← Page CRUD
│ │ └── FrontendComponentController.php ← Component CRUD
│ └── Requests/Backend/
│ └── FrontendSiteRequest.php ← Site validation
├── Models/Backend/
│ ├── FrontendSite.php
│ ├── FrontendComponent.php
│ ├── FrontendPage.php
│ └── FrontendPageComponent.php
└── Services/
├── PortalHandler.php ← Render engine
├── BladeSyncService.php ← Storage sync
└── FrontendSiteCacheService.php ← Cache
database/
├── migrations/
│ ├── xxxx_create_frontend_components_table.php
│ ├── xxxx_create_frontend_sites_table.php
│ ├── xxxx_create_frontend_pages_table.php
│ └── xxxx_create_frontend_page_components_table.php
└── seeders/
├── BackendMenuSeeder.php ← Menu seeder
├── AdminRolePermissionSeeder.php ← Permission seeder
└── FrontendSiteDemoSeeder.php ← Demo data seeder
storage/app/private/
├── site-meta.json
├── PHP/*.blade.php ← Component content (navbar, hero, etc)
├── LAYOUTS/SITE/*.blade.php ← Site layout
├── LAYOUTS/PAGE/{site}/*.blade.php ← Page layout (override site layout)
├── ENTRY/PAGE/{site}/*.blade.php ← Entry scripts (entry_script)
└── PAGE_CONTENT/{site}/*.blade.php ← Page Content (page_content)
resources/views/
├── frontend/cms.blade.php
└── backend/module/frontendSite/
├── index.blade.php
├── site/{index,create,edit}.blade.php
├── component/{index,create,edit}.blade.php
└── page/{index,create,edit}.blade.php
```