CMS Studio
Kompletný CMS modul: verejný front end, administrácia, šablóny, schéma, AJAX zoznamy a dotapp.js.
Živé štúdio: /documentation/examples/run/studio.
Zamknutý desk: /documentation/examples/run/studio/admin.
Čo staviate
CMS nie je kontaktný formulár. Sú to dva produkty v jednom module: verejná webstránka, ktorú ľudia čítajú, a administrácia, v ktorej editori menia obsah. Tento návod stavia oboje v app/modules/Studio. Živá ukážka je bratislavské softvérové štúdio Lumen Press — úvod, služby, insights, o nás, kontakt, plus desk s článkami, stránkami, menu, médiami a nastaveniami.
Na tejto verejnej ukážke sa nedá prihlásiť
Prihlasovací formulár je skutočný fo-rm. PHP payload overí a vždy ho odmietne. Auth::login() sa nikdy nevolá. Každý save, delete, reorder aj settings POST zastaví DeskGate@write. Nikto sem nemôže vložiť obsah. Stránky desk-u sú náhľad iba na čítanie, aby ste videli UI.
Túto stránku použite ako recept na ostrý CMS. Vymeňte katalóg v PHP za tabuľky studio_*, desk dajte za Auth::isLogged() a šablóny nechajte.
Title a JSON-LD
Každá živá Studio stránka začína title Example: a JSON-LD je TechArticle + LearningResource.
Žiadne NewsArticle ani Product schema.
- Modul Users — skutočné prihlásenie, registrácia, 2FA,
AuthGate - Zabezpečené formuláre —
fo-rm,formName, CRC - AJAX zoznamy —
paginate(), šifrované id, hľadanie - DotApper CLI — scaffold modulu, kostru nevytvárajte ručne
Mapa modulu
Jeden modul vlastní celý CMS. „Frontend app“ a „admin app“ neseparujte, kým to naozaj nie sú dva produkty.
| Časť | Cesta | Úloha |
|---|---|---|
| Routy | module.init.php | Verejné URL + /admin/*. Zápisy majú ->before('#Studio:DeskGate@write!'). |
| Verejný web | Controllers/Site.php | Home, článok, téma, about, kontakt. |
| Desk | Controllers/Admin.php | Login (tu vždy zlyhá), prehľad, články, stránky, menu, médiá, nastavenia. |
| Brána | Middleware/DeskGate.php | Demo: odmietni každý zápis. Produkcia: Auth::isLogged() + Auth::can(). |
| Katalóg | Libraries/Press.php | Demo obsah v PHP. Produkcia: DB::module('RAW') na studio_*. |
| Schéma | Installation.php | Verzované tabuľky: články, stránky, témy, menu, médiá, nastavenia. |
| Šablóny | views/*.view.php | Celé HTML dokumenty + fragmenty pre AJAX. Nie Blade, nie Twig, nie include. |
| JS | assets/js/studio.js, admin.js | $dotapp().form a $dotapp().load. Nie jQuery. |
Živé URL
| URL | Čo uvidíte |
|---|---|
| /documentation/examples/run/studio | Úvod štúdia |
/documentation/examples/run/studio/article/{slug} | Jeden insight |
| /topics | Služby |
| /insights | Index insightov |
| /admin | Login, ktorý vždy zlyhá |
| /admin/desk | Náhľad desk-u len na čítanie |
| /admin/articles | Stránkovaný AJAX zoznam (hľadajte „cloud“) |
Prefix je Config::module('Studio', 'prefix'), predvolene /documentation/examples/run/studio. Na vlastnom webe dajte verejnému webu / a desk-u /admin.
Súbory na disku
Po DotApperi doplňte tieto cesty. Živá ukážka číta články, stránky, témy a navigáciu z Libraries/Press.php.
Installation.php je produkčná schéma — celý súbor je nižšie na tejto stránke. Na verejnej ukážke sa nespúšťa.
app/modules/Studio/
module.init.php routy
Installation.php tabuľky studio_* (celý súbor nižšie)
Libraries/View.php Renderer helper
Libraries/Press.php demo katalóg + menu()
Libraries/Mark.php Example SEO
Controllers/Site.php verejný web
Controllers/Admin.php desk
Middleware/DeskGate.php zámok zápisu
views/site.view.php verejný chrome (nav cyklí $menu)
views/site-*.view.php verejné fragmenty
views/admin.view.php desk chrome
views/admin-*.view.php desk fragmenty
assets/css/studio.css
assets/js/studio.js
assets/js/admin.js
assets/img/ logo, hero, praktiky, tím
Scaffold with DotApper
Never hand-create the module skeleton. Generate it, then fill in routes and classes.
php dotapper.php --create-module=Studio
php dotapper.php --module=Studio --create-controller=Site
php dotapper.php --module=Studio --create-controller=Admin
php dotapper.php --module=Studio --create-middleware=DeskGate
--module= must appear before --create-controller / --create-middleware.
That creates app/modules/Studio/ with Controllers/, Middleware/, Libraries/, views/, assets/, and module.init.php.
DotApper also drops placeholder files you can ignore or replace: Api/Api.php, Controllers/Controller.php, views/clean.view.php, views/layouts/example.layout.php, module.listeners.php.
What you then write by hand (this walkthrough):
| File | You write |
|---|---|
module.init.php | Prefix + every public and desk route |
Installation.php | All studio_* tables (full file in the Schema section) |
Libraries/View.php | Renderer helper: document vs fragment |
Libraries/Press.php | Demo catalog (production: DB queries) |
Libraries/Mark.php | Example titles + TechArticle JSON-LD |
Controllers/Site.php | Public site |
Controllers/Admin.php | Desk (login always fails here) |
Middleware/DeskGate.php | Reject every write on the public demo |
views/*.view.php | One chrome document + inner fragments |
assets/css/studio.css, assets/js/*.js | Look and $dotapp behaviour |
Routes: the complete module.init.php
Static controllers: 'Studio:Site@home!'. Trailing ! is required. Pair each path with and without a trailing slash.
Dynamic article slugs are not STATIC_ROUTE. Register exact admin paths before /admin/articles/{slug:s} so /admin/articles is not swallowed.
Write POSTs attach ->before('#Studio:DeskGate@write!'). There is no Laravel Route::group().
File: app/modules/Studio/module.init.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Router;
class Module extends \Dotsystems\App\Parts\Module
{
public function initialize($dotApp)
{
Config::module('Studio', 'prefix') ?? Config::module('Studio', 'prefix', '/documentation/examples/run/studio');
$p = rtrim((string) Config::module('Studio', 'prefix'), '/');
$pair = function (string $path): array {
$path = rtrim($path, '/');
return [$path, $path . '/'];
};
Router::get($pair($p), 'Studio:Site@home!', Router::STATIC_ROUTE);
Router::get($pair($p . '/topics'), 'Studio:Site@topics!', Router::STATIC_ROUTE);
Router::get($pair($p . '/insights'), 'Studio:Site@insights!', Router::STATIC_ROUTE);
Router::get($pair($p . '/about'), 'Studio:Site@about!', Router::STATIC_ROUTE);
Router::get($pair($p . '/contact'), 'Studio:Site@contact!', Router::STATIC_ROUTE);
Router::post($pair($p . '/contact'), 'Studio:Site@contactSave!', Router::STATIC_ROUTE);
Router::get($p . '/article/{slug:s}', 'Studio:Site@article!');
Router::get($p . '/topic/{slug:s}', 'Studio:Site@topic!');
Router::get($pair($p . '/admin'), 'Studio:Admin@login!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin'), 'Studio:Admin@loginSave!', Router::STATIC_ROUTE);
Router::get($pair($p . '/admin/desk'), 'Studio:Admin@desk!', Router::STATIC_ROUTE);
Router::get($pair($p . '/admin/articles'), 'Studio:Admin@articles!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/articles/list'), 'Studio:Admin@articlesList!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/articles/save'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::post($pair($p . '/admin/articles/delete'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($p . '/admin/articles/{slug:s}', 'Studio:Admin@article!');
Router::get($pair($p . '/admin/pages'), 'Studio:Admin@pages!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/pages/save'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($pair($p . '/admin/menu'), 'Studio:Admin@menu!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/menu/save'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($pair($p . '/admin/media'), 'Studio:Admin@media!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/media/list'), 'Studio:Admin@mediaList!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/media/delete'), 'Studio:Admin@lockedWrite!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
Router::get($pair($p . '/admin/settings'), 'Studio:Admin@settings!', Router::STATIC_ROUTE);
Router::post($pair($p . '/admin/settings'), 'Studio:Admin@settingsSave!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@write!');
}
public function initializeRoutes()
{
return ['/documentation/examples/run/studio', '/documentation/examples/run/studio/*'];
}
public function initializeCondition($routeMatch)
{
return $routeMatch;
}
}
new Module($dotApp);
Read slugs with $request->matchData()['slug']. Missing article → new Response(404, 'Article not found').
Renderer helper: the complete View.php
Call Renderer::new()->module('Studio')->setView($name) before setViewVar.
A view that fails to render returns "" — log it and return HTTP 500.
page() is a full HTML document (chrome). fragment() is an inner view swapped into {{ var: $bodyHtml }}.
There is no Blade, no Twig, no PHP include inside a view.
File: app/modules/Studio/Libraries/View.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio\Libraries;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Renderer;
use Dotsystems\App\Parts\Response;
class View
{
public static function prefix(): string
{
return rtrim((string) Config::module('Studio', 'prefix'), '/');
}
public static function docsUrl(): string
{
return '/documentation/examples/studio';
}
public static function dotappJs(): string
{
return '/assets/dotapp/dotapp.js?n=' . bin2hex(random_bytes(4));
}
public static function assetV(): string
{
return '270';
}
public static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function seoPack(string $pageLabel, string $path, string $robots = 'index,follow'): array
{
$title = Mark::title($pageLabel);
$desc = Mark::description();
return [
'title' => $title,
'metaDescription' => $desc,
'canonical' => Mark::canonical($path),
'jsonLd' => Mark::jsonLd($title, $desc, $path),
'robots' => $robots,
];
}
public static function page(string $name, array $vars)
{
$r = Renderer::new()->module('Studio')->setView($name, 'clean');
foreach ($vars as $key => $value) {
$r->setViewVar($key, $value);
}
$html = $r->renderView();
if ($html === '') {
Logger::use()->error('Studio view empty', ['view' => $name]);
return new Response(500, 'Template error');
}
return $html;
}
public static function fragment(string $name, array $vars): string
{
$r = Renderer::new()->module('Studio')->setView($name, 'clean');
foreach ($vars as $key => $value) {
$r->setViewVar($key, $value);
}
$html = $r->renderView();
if ($html === '') {
Logger::use()->error('Studio fragment empty', ['view' => $name]);
return '<p class="lp-empty">Could not render this block.</p>';
}
return $html;
}
}
Schéma: celý súbor Installation.php
CMS nie je „jedna tabuľka článkov“. Magazín, desk, menu, médiá aj nastavenia potrebujú vlastnú tabuľku, ktorú vlastní modul.
Každá tabuľka je studio_*. Nikdy neprefixované názvy, nikdy dotapp_* pre dáta štúdia.
Funkčné DB::migrate() neexistuje. Verziované SQL píšete do Installation.php, ktoré rozširuje Installer.
Verejná ukážka tento inštalátor nespúšťa. Živé stránky čítajú PHP polia z Libraries/Press.php, aby návštevník nemohol urobiť INSERT.
Vo vlastnom projekte tento súbor skopírujete a z initialize() zavoláte Installation::module('Studio')->install(), keď je databáza nastavená.
Na čo ktorá tabuľka slúži — toto zloženie starý snippet schoval:
| Tabuľka | Kto ju plní | Kto ju číta |
|---|---|---|
studio_topics | Desk → Topics (alebo seed) | Verejný index tém, topic_id článku |
studio_articles | Desk → editor článkov | Úvod, URL článku, výpis témy |
studio_pages | Desk → Pages (About, tiráž, legal) | /about a ďalšie statické dokumenty |
studio_menus | Desk → Menu (jeden riadok na navigáciu, code = primary) | Join na položky |
studio_menu_items | Desk → riadky menu (label, href, pos) | site.view.php ich cyklí do <nav> |
studio_media | Desk → upload | Knižnica, obrázky článkov |
studio_settings | Desk → Settings | Názov webu, tagline v chrome |
studio_installations | ensureTable() / markDone() | Idempotencia — verzia 1.0.0 raz |
Čítajte triedu zhora nadol. installer() vracia pole callbackov podľa verzie. alreadyDone('1.0.0') podľa potreby vytvorí studio_installations a skončí, ak je verzia už označená.
Potom v slučke beží každý CREATE TABLE. Až keď uspejú všetky, volá sa markDone('1.0.0') — nie v success callbacku prvej tabuľky. To by označilo modul ako nainštalovaný po jednej tabuľke.
uninstaller() maže v opačnom poradí (položky pred menu, články pred témami).
Súbor: app/modules/Studio/Installation.php — skopírujte celý súbor
<?php
namespace Dotsystems\App\Modules\Studio;
use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Installer;
use Dotsystems\App\Parts\Logger;
class Installation extends Installer
{
public static function installer()
{
return [
'1.0.0' => function () {
if (self::alreadyDone('1.0.0')) {
return;
}
$sql = [];
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_topics` (
`id` INT NOT NULL AUTO_INCREMENT,
`slug` VARCHAR(80) NOT NULL,
`title` VARCHAR(160) NOT NULL,
`blurb` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_articles` (
`id` INT NOT NULL AUTO_INCREMENT,
`topic_id` INT NOT NULL DEFAULT 0,
`slug` VARCHAR(160) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`excerpt` VARCHAR(255) NOT NULL DEFAULT '',
`body` MEDIUMTEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`published_at` DATETIME NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`),
KEY `topic_status` (`topic_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_pages` (
`id` INT NOT NULL AUTO_INCREMENT,
`slug` VARCHAR(160) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`body` MEDIUMTEXT NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_menus` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(40) NOT NULL,
`title` VARCHAR(120) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_menu_items` (
`id` INT NOT NULL AUTO_INCREMENT,
`menu_id` INT NOT NULL,
`label` VARCHAR(120) NOT NULL,
`href` VARCHAR(255) NOT NULL,
`pos` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `menu_pos` (`menu_id`, `pos`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_media` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(200) NOT NULL,
`kind` VARCHAR(40) NOT NULL DEFAULT 'image',
`path` VARCHAR(255) NOT NULL,
`bytes` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `studio_settings` (
`id` INT NOT NULL AUTO_INCREMENT,
`skey` VARCHAR(80) NOT NULL,
`svalue` TEXT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `skey` (`skey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$ok = true;
foreach ($sql as $chunk) {
DB::module('RAW')->q(function ($qb) use ($chunk) {
$qb->raw($chunk, []);
})->execute(
function () {},
function ($error) use (&$ok) {
$ok = false;
Logger::use()->error('Studio 1.0.0 failed', $error);
}
);
if (!$ok) {
return;
}
}
self::markDone('1.0.0');
},
];
}
public static function uninstaller()
{
return [
'1.0.0' => function () {
$tables = [
'studio_settings',
'studio_media',
'studio_menu_items',
'studio_menus',
'studio_pages',
'studio_articles',
'studio_topics',
'studio_installations',
];
foreach ($tables as $table) {
DB::module('RAW')->q(fn($qb) => $qb->raw('DROP TABLE IF EXISTS `' . $table . '`', []))
->execute(null, function ($e) use ($table) {
Logger::use()->error($table . ' drop failed', $e);
});
}
},
];
}
private static function ensureTable(): void
{
DB::module('RAW')->q(function ($qb) {
$qb->raw(
"CREATE TABLE IF NOT EXISTS `studio_installations` (
`id` INT NOT NULL AUTO_INCREMENT,
`installation_id` VARCHAR(100) NOT NULL,
`installed_at` DATETIME NOT NULL,
`status` TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `ver` (`installation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
[]
);
})->execute(null, function ($e) {
Logger::use()->error('studio_installations table', $e);
});
}
private static function alreadyDone(string $version): bool
{
self::ensureTable();
$rows = DB::module('RAW')->q(function ($qb) use ($version) {
$qb->raw(
'SELECT 1 AS ok FROM `studio_installations` WHERE `installation_id` = :v AND `status` = 1 LIMIT 1',
['v' => $version]
);
})->all();
return !empty($rows);
}
private static function markDone(string $version): void
{
DB::module('RAW')->q(function ($qb) use ($version) {
$qb->insert('studio_installations', [
'installation_id' => $version,
'installed_at' => date('Y-m-d H:i:s'),
'status' => 1,
]);
})->execute(null, function ($e) {
Logger::use()->error('Studio markDone', $e);
});
}
}
Na ostrom webe to zapojte z module.init.php (nie na tejto verejnej ukážke):
$dbs = Config::get('databases');
if (is_array($dbs) && $dbs !== []) {
try {
Installation::module('Studio')->install();
} catch (\Throwable $e) {
Logger::use()->error('Studio install skipped', ['msg' => $e->getMessage()]);
}
}
Zoznamy, ktoré môžu rásť, musia použiť ->paginate($perPage, $page) plus AJAX pager od prvého dňa.
Produkčný zoznam článkov (ukážka namiesto toho používa Press::paginateArticles() v pamäti):
$result = DB::module('RAW')->q(function ($qb) use ($q, $useSearch) {
$qb->select(['id', 'title', 'status', 'published_at'])
->from('studio_articles')
->orderBy('id', 'DESC');
if ($useSearch) {
$esc = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $q);
$qb->where('title', 'LIKE', '%' . $esc . '%');
}
})->paginate(10, $page);
Produkčné menu pre verejné <nav> — odtiaľto prídu odkazy Home / Services / Insights / Company / Contact, keď opustíte Press.php:
$menu = DB::module('RAW')->q(function ($qb) {
$qb->select(['i.id', 'i.label', 'i.href', 'i.pos'])
->from('studio_menu_items', 'i')
->join('studio_menus m', 'm.id', '=', 'i.menu_id')
->where('m.code', '=', 'primary')
->orderBy('i.pos', 'ASC');
})->all();
Public site: where templates and the menu come from
The studio site is two layers. A document view owns <html>, the EXAMPLE ribbon, the sticky header, dropdowns, the footer, CSS, and scripts.
An inner view is only the <main> body (home, one insight, contact form).
PHP renders the inner view first, then injects that HTML string as $bodyHtml into the document.
That is why you do not include templates and you do not put a second <html> in site-home.view.php.
Every public view file
| File | Kind | What it paints |
|---|---|---|
views/site.view.php | Document | Ribbon, logo, dropdown <nav> from $menu, $bodyHtml, footer, studio.js |
views/site-home.view.php | Fragment | Hero, services, featured insight, team |
views/site-article.view.php | Fragment | One insight |
views/site-topics.view.php | Fragment | Services index |
views/site-insights.view.php | Fragment | Insight index |
views/site-topic.view.php | Fragment | Insights in one practice |
views/site-page.view.php | Fragment | About / leadership |
views/site-contact.view.php | Fragment | fo-rm named contactForm |
assets/css/studio.css | CSS | Served as /assets/modules/Studio/css/studio.css |
assets/js/studio.js | JS | Drawer menu + contact $dotapp().form |
assets/img/ | Images | Logo, hero, practices, team — /assets/modules/Studio/img/ |
Template rules: close with {{ /if }} and {{ /foreach }}, never endif.
Print with {{ var: $title }} only — there is no {{ $title }}.
Layout partials use {{ layout:name }}. PHP include in a view is forbidden.
How a page is assembled
Site::home() loads sample rows from Press::articles(), then calls private site('site-home', ...).
That helper renders the fragment, then the document. Copy this method — it is the whole composition:
File: app/modules/Studio/Controllers/Site.php — method site()
private static function site(string $inner, string $pageLabel, string $path, array $vars)
{
$p = View::prefix();
$innerVars = $vars;
$innerVars['prefix'] = $p;
$body = View::fragment($inner, $innerVars);
$nav = (string) ($vars['nav'] ?? '');
return View::page('site', array_merge(View::seoPack($pageLabel, $path), [
'nav' => $nav,
'bodyHtml' => $body,
'prefix' => $p,
'docsUrl' => View::docsUrl(),
'homeUrl' => $p . '/',
'topicsUrl' => $p . '/topics',
'insightsUrl' => $p . '/insights',
'aboutUrl' => $p . '/about',
'contactUrl' => $p . '/contact',
'adminUrl' => $p . '/admin',
'deskUrl' => $p . '/admin/desk',
'dotappJs' => View::dotappJs(),
'assetV' => View::assetV(),
'logoUrl' => Press::asset('lumen-logo.png'),
'settings' => Press::settings(),
'topics' => Press::topics(),
'menu' => Press::nav($nav),
]));
}
Where the menu comes from
The desk table still uses a flat Press::menu() (Home / Services / Insights / Company / Contact).
The public header uses Press::nav($active), which adds dropdown children for Services, Insights, and Company.
site.view.php loops $menu into <nav> and nested $item['kids'] into the panels.
On a real CMS you replace both helpers with a query on studio_menu_items (see Schema). The view file does not change.
File: app/modules/Studio/Libraries/Press.php — method menu()
public static function menu(): array
{
$p = View::prefix();
return [
['id' => 1, 'label' => 'Home', 'href' => $p . '/', 'pos' => 1],
['id' => 2, 'label' => 'Services', 'href' => $p . '/topics', 'pos' => 2],
['id' => 3, 'label' => 'Insights', 'href' => $p . '/insights', 'pos' => 3],
['id' => 4, 'label' => 'Company', 'href' => $p . '/about', 'pos' => 4],
['id' => 5, 'label' => 'Contact', 'href' => $p . '/contact', 'pos' => 5],
];
}
Document chrome
File: app/modules/Studio/views/site.view.php — copy the live file for the full head and footer
<header class="lp-top">
<a class="lp-logo" href="{{ var: $homeUrl }}">
<img class="lp-logo-mark" src="{{ var: $logoUrl }}" width="42" height="42" alt="" />
<span class="lp-logo-type">LUMEN<span>PRESS</span></span>
<span class="lp-example-tag">example</span>
</a>
<button type="button" class="lp-burger" id="lpMenuBtn" aria-controls="lpNav" aria-expanded="false">Menu</button>
<nav class="lp-nav" id="lpNav" aria-label="Studio">
{{ foreach $menu as $item }}
{{ if $item['drop'] }}
<div class="lp-drop {{ var: $item['cls'] }}">
<a class="lp-drop-link" href="{{ var: $item['href'] }}">{{ var: $item['label'] }}</a>
<button type="button" class="lp-drop-caret" aria-expanded="false"></button>
<div class="lp-drop-panel">
{{ foreach $item['kids'] as $kid }}
<a href="{{ var: $kid['href'] }}"><strong>{{ var: $kid['label'] }}</strong><span>{{ var: $kid['blurb'] }}</span></a>
{{ /foreach }}
</div>
</div>
{{ else }}
<a href="{{ var: $item['href'] }}" class="{{ var: $item['cls'] }}">{{ var: $item['label'] }}</a>
{{ /if }}
{{ /foreach }}
</nav>
</header>
<main class="lp-main" id="main">{{ var: $bodyHtml }}</main>
The live file also has Open Graph tags, a four-column footer, and googlebot-news: noindex. Copy app/modules/Studio/views/site.view.php for the exact document.
Home fragment
No <html> here. Variables come from Site::home(): $settings, $featured, $topics, $articles, $team, $clients, $prefix.
The live home is a full studio landing (hero photograph, services, insights, partners). Copy app/modules/Studio/views/site-home.view.php.
File: app/modules/Studio/views/site-home.view.php — opening of the live file
<section class="lp-hero">
<div>
<p class="lp-kicker">Bratislava software studio</p>
<h1>{{ var: $settings['tagline'] }}</h1>
<p class="lp-lead">Lumen Press designs platforms, cloud estates, and product surfaces for operators who still want the keys.</p>
<div class="lp-hero-actions">
<a class="lp-btn" href="{{ var: $prefix }}/topics">See the work</a>
<a class="lp-btn lp-btn-ghost" href="{{ var: $prefix }}/contact">Start a conversation</a>
</div>
</div>
</section>
Contact form
Always $request->crcCheck() first. Always $request->data(true)['data'] for the payload. Always DotApp::DotApp()->ajaxReply($body, $code).
Put {{ formName(contactForm) }} between the <fo-rm> tags.
$answer = $request->form(['POST'], 'contactForm', function ($request) {
$data = $request->data(true)['data'] ?? [];
$email = trim((string) ($data['email'] ?? ''));
if (!Validator::isEmail($email)) {
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Enter a valid email.']];
}
return ['code' => 200, 'body' => [
'status' => 1,
'message' => 'Thanks. We received your note.',
]];
}, function () {
return ['code' => 403, 'body' => ['status' => 0, 'message' => 'Invalid signature']];
}, $request->getPath());
Administration: desk templates and the write lock
The desk is the same module, a second document. Sidebar + lock banner live in admin.view.php.
On a phone the sidebar is an off-canvas drawer (#stDeskMenuBtn).
Each screen is a fragment rendered into {{ var: $bodyHtml }} the same way as the public site.
Login is a separate document: admin-login.view.php with a fo-rm named loginForm.
Every desk view file
| File | Kind | What it paints |
|---|---|---|
views/admin-login.view.php | Document | Locked login (always status 0) |
views/admin.view.php | Document | Mobile drawer (#stDeskMenuBtn), sidebar, error banners, confirm modal, admin.js |
views/admin-desk.view.php | Fragment | Overview counts |
views/admin-articles.view.php | Fragment | Search + list wrap |
views/admin-articles-inner.view.php | AJAX fragment | Table + pager HTML returned in reply.html |
views/admin-article.view.php | Fragment | Editor fo-rm saveArticle (save is rejected) |
views/admin-pages.view.php | Fragment | Static pages list |
views/admin-menu.view.php | Fragment | Menu rows + up/down buttons |
views/admin-media.view.php / admin-media-inner.view.php | Fragment + AJAX | Paginated library |
views/admin-settings.view.php | Fragment | Sitename fo-rm (save is rejected) |
assets/js/admin.js | JS | Forms, lists, modal confirm |
Locked login (this demo)
The handler still uses crcCheck + $request->form(..., 'loginForm', ...). It never calls Auth::login().
Any email/password pair returns status 0. That is intentional.
return ['code' => 200, 'body' => [
'status' => 0,
'locked' => 1,
'message' => 'Unable to sign in.',
]];
Production login
Copy the Users module pattern: Auth::login(['email' => $email, 'password' => $password, 'stage' => 0], $remember),
then redirect to the desk. Protect GET desk routes. Do not ship that gate on this public docs site.
Router::get($pair($p . '/admin/desk'), 'Studio:Admin@desk!', Router::STATIC_ROUTE)
->before('#Studio:DeskGate@check!');
public static function check($request)
{
if (!Auth::isLogged()) {
return Response::redirect($prefix . '/admin', 302);
}
if (!Auth::can(['Studio.desk'])) {
return new Response(403, 'Forbidden');
}
}
Write lock: the complete DeskGate.php
Every mutating admin POST is registered with ->before('#Studio:DeskGate@write!').
Returning a Response from a before-hook stops the controller. The article editor is still a real fo-rm so loaders work — Save posts, this gate rejects, the row does not change.
Delete uses a graphical confirm (never alert / confirm). Menu up/down is buttons + load(), not a fo-rm per arrow.
File: app/modules/Studio/Middleware/DeskGate.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Studio\Middleware;
use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Response;
class DeskGate extends \Dotsystems\App\Parts\ModuleMiddleware
{
public static function write($request)
{
$body = DotApp::DotApp()->ajaxReply([
'status' => 0,
'locked' => 1,
'message' => 'This public demo never writes content. Sign-in is disabled, so nobody can insert or change rows here.',
], 200);
return new Response(200, $body);
}
}
Desk chrome
File: app/modules/Studio/views/admin.view.php — document shell (sidebar + fragment slot)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ var: $title }}</title>
<meta name="description" content="{{ var: $metaDescription }}" />
<meta name="robots" content="{{ var: $robots }}" />
<meta name="googlebot" content="{{ var: $robots }}" />
<meta name="googlebot-news" content="noindex" />
<link rel="canonical" href="{{ var: $canonical }}" />
<meta property="og:title" content="{{ var: $title }}" />
<meta property="og:description" content="{{ var: $metaDescription }}" />
<script type="application/ld+json">{{ var: $jsonLd }}</script>
<link rel="stylesheet" href="/assets/modules/Studio/css/studio.css?v={{ var: $assetV }}" />
</head>
<body class="st-body">
<div class="st-lock" role="note"><strong>EXAMPLE</strong> — DotApp PHP Framework 2.0 documentation demo.</div>
<header class="st-desk-bar">
<a class="st-brand" href="{{ var: $deskUrl }}">Lumen desk</a>
<button type="button" class="st-burger" id="stDeskMenuBtn" aria-controls="stSide" aria-expanded="false" aria-label="Open menu"><span></span><span></span><span></span></button>
</header>
<div class="st-scrim" id="stScrim" hidden="hidden"></div>
<div class="st-shell">
<aside class="st-side" id="stSide">
<div class="st-nav-head">
<a class="st-brand" href="{{ var: $deskUrl }}">Lumen desk</a>
<button type="button" class="st-nav-close" id="stDeskMenuClose" aria-label="Close menu">Close</button>
</div>
<nav>
<a href="{{ var: $deskUrl }}" class="{{ if $nav === "desk" }}is-active{{ /if }}">Overview</a>
<a href="{{ var: $articlesUrl }}" class="{{ if $nav === "articles" }}is-active{{ /if }}">Articles</a>
<a href="{{ var: $pagesUrl }}" class="{{ if $nav === "pages" }}is-active{{ /if }}">Pages</a>
<a href="{{ var: $menuUrl }}" class="{{ if $nav === "menu" }}is-active{{ /if }}">Menu</a>
<a href="{{ var: $mediaUrl }}" class="{{ if $nav === "media" }}is-active{{ /if }}">Media</a>
<a href="{{ var: $settingsUrl }}" class="{{ if $nav === "settings" }}is-active{{ /if }}">Settings</a>
</nav>
<p class="st-side-meta"><a href="{{ var: $homeUrl }}">Public site</a><a href="{{ var: $loginUrl }}">Login (locked)</a><a href="{{ var: $docsUrl }}">CMS walkthrough</a></p>
</aside>
<main class="st-main">
<div id="error-message" class="lp-error" hide="hide"></div>
<div id="status" class="lp-status" hide="hide"></div>
{{ var: $bodyHtml }}
</main>
</div>
<div id="stConfirm" class="st-modal" hidden="hidden">
<div class="st-modal-card">
<h2 id="stConfirmTitle">Delete this row?</h2>
<p id="stConfirmText">On a real CMS this would remove the record. On this demo the request is rejected.</p>
<button type="button" class="lp-btn js-st-ok">Delete</button>
<button type="button" class="lp-btn lp-btn-ghost js-st-cancel">Cancel</button>
</div>
</div>
<script src="{{ var: $dotappJs }}"></script>
<script src="/assets/modules/Studio/js/admin.js?v={{ var: $assetV }}"></script>
</body>
</html>
Encrypted ids
Different extra keys per field. Encryption is not authorization — on a real desk still call Auth::can(). These are field names, not secrets.
$enc = Crypto::encrypt((string) $row['id'], 'Studio.article.id');
$id = Crypto::decrypt((string) ($data['id'] ?? ''), 'Studio.article.id');
if ($id === false) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Invalid item.'], 200);
}
Keys used in this module: Studio.article.id, Studio.page.id, Studio.menu.id, Studio.media.id, Studio.topic.slug.
dotapp.js: formuláre, zoznamy, confirm
Najprv načítajte /assets/dotapp/dotapp.js. Logika stránky počúva udalosť dotapp.
$dotapp nie je jQuery. $dotapp().live() volá handler(element, event) — prvý argument je nájdený uzol.
(function () {
var runMe = function ($dotapp) {
$dotapp().form("#loginForm").before(function (data, form) {
if ($dotapp(form).attr("blocked") == 1) return $dotapp().halt();
$dotapp(form).attr("blocked", "1");
$dotapp("#loginBtn").attr("loading", "true").attr("loader", "dots");
}).after(function (data, response, form) {
var reply = $dotapp().parseReply(response);
if (reply && reply.message) $dotapp("#error-message").attr("hide", "false").html(reply.message);
$dotapp(form).attr("blocked", "0");
$dotapp("#loginBtn").removeAttr("loading").removeAttr("loader");
});
};
if (window.$dotapp) runMe(window.$dotapp);
else window.addEventListener("dotapp", function () { runMe(window.$dotapp); }, { once: true });
})();
Zoznamy článkov a médií: debounce input, hľadanie od troch znakov, overlay .lp_busy počas letu, záplata #listInner cez reply.html.
Pager tlačidlá sú type="button" s data-page. Lepkavá hlavička tabuľky + <mark> na zhody sú povinné pri lookup zoznamoch.
$dotapp().live("click", ".js-st-page", function (el, ev) {
var btn = (el && el.nodeType === 1) ? el : ev.currentTarget;
var page = parseInt(btn.getAttribute("data-page"), 10) || 1;
$dotapp().load(listUrl, "POST", { page: page, q: currentQuery }, function (raw) {
var reply = $dotapp().parseReply(raw);
if (reply && reply.html) $dotapp("#listInner").html(reply.html);
});
});
Súbory: $dotapp().uploadFile(file, url, progress) — nikdy FormData na load() ani fo-rm (CRC neobalí binár).
Verejná ukážka upload nenasadzuje, aby návštevník nemohol položiť súbory na server.
Session pre košík/drafty/filtre: DSM::use('Studio'), nikdy $_SESSION.
Produkčný checklist
- Scaffold cez DotApper. Tabuľky len
studio_*. - Verejný web: slugy, témy,
fo-rmkontakt, CSS modulu,Renderer+ fragmenty. - Desk:
Auth::isLogged()+Auth::can('Studio.desk')na každom GET okrem login stránky. - Zápisy:
crcCheck, unikátne extra kľúče, znovaAuth::can, potom INSERT/UPDATE. - Rastúce zoznamy:
paginate()+ AJAX pager + hľadanie od 3 znakov + overlay + prázdny stav + sticky header + highlight. - Akcie v riadku: tlačidlá +
load(). Jeden editorfo-rmna obrazovku. Delete cez modal. - Médiá:
uploadFile, stránkovaná knižnica, šifrované media id. - Nevymýšľajte Blade/Eloquent/jQuery API. Ak to nie je v AIRULES, otvorte
app/partslen na čítanie.
Vyskúšajte živú ukážku
Otvorte bežiaci modul. Nemusíte sa vracať na začiatok tejto stránky.
Otvoriť Lumen Press Otvoriť desk len na čítanie
- Otvorte štúdio: /documentation/examples/run/studio
- Otvorte službu, prečítajte insight, odošlite kontakt.
- Otvorte administráciu a zadajte ľubovoľné heslo — zlyhá.
- Otvorte náhľad desk-u, hľadajte články, kliknite Save / Delete / šípky menu — každý zápis je odmietnutý.
Zdroj: app/modules/Studio/. Návod: tieto layouty v Docs/views/layouts/pages/examples/studio*.layout.php.