11<?php declare (strict_types=1 );
22// Copyright (C) 2015-2026 Mark Constable <mc@netserva.org> (MIT License)
3+ // 04-Themes: App Shell with dual sidebars (LHS navigation, RHS settings)
34
45if (!class_exists ('Ctx ' )) {
56readonly class Ctx {
67 public array $ in ;
78 public function __construct (
89 public string $ email = 'mc@netserva.org ' ,
9- array $ in = ['o ' => 'Home ' , 'm ' => 'list ' , 't ' => ' Simple ' , ' x ' => '' ],
10+ array $ in = ['o ' => 'Home ' , 'm ' => 'list ' , 'x ' => '' ],
1011 public array $ out = ['doc ' => 'SPE::04 ' , 'head ' => '' , 'main ' => '' , 'foot ' => '' ],
11- public array $ nav = [['🏠 Home ' , 'Home ' ], ['📖 About ' , 'About ' ], ['✉️ Contact ' , 'Contact ' ]],
12- public array $ themes = [['🎨 Simple ' , 'Simple ' ], ['🎨 TopNav ' , 'TopNav ' ], ['🎨 SideBar ' , 'SideBar ' ]]
12+ public array $ nav = [
13+ ['home ' , 'Home ' , 'Home ' ],
14+ ['book-open ' ,'About ' , 'About ' ],
15+ ['mail ' , 'Contact ' , 'Contact ' ],
16+ ],
17+ public array $ colors = [
18+ ['circle ' , 'Stone ' , 'default ' ],
19+ ['waves ' , 'Ocean ' , 'ocean ' ],
20+ ['trees ' , 'Forest ' , 'forest ' ],
21+ ['sunset ' , 'Sunset ' , 'sunset ' ],
22+ ]
1323 ) {
14- $ this ->in = array_map (static fn ($ k , $ v ) => (isset ($ _GET [$ k ]) && is_string ($ _GET [$ k ]) ? $ _GET [$ k ] : $ v )
15- |> trim (...)
16- |> htmlspecialchars (...), array_keys ($ in ), $ in )
24+ $ this ->in = array_map (static fn ($ k , $ v ) => $ _GET [$ k ] ?? $ v
25+ |> trim (...) |> htmlspecialchars (...), array_keys ($ in ), $ in )
1726 |> (static fn ($ v ) => array_combine (array_keys ($ in ), $ v ));
1827 }
1928}
2029
2130readonly class Init {
2231 private array $ out ;
23-
2432 public function __construct (private Ctx $ ctx ) {
25- [$ o , $ m, $ t ] = [$ ctx ->in ['o ' ], $ ctx ->in ['m ' ], $ ctx -> in [ ' t ' ]];
33+ [$ o , $ m ] = [$ ctx ->in ['o ' ], $ ctx ->in ['m ' ]];
2634 $ model = "{$ o }Model " ;
2735 $ ary = class_exists ($ model ) ? (new $ model ($ ctx ))->$ m () : [];
2836 $ view = "{$ o }View " ;
2937 $ main = class_exists ($ view ) ? (new $ view ($ ctx , $ ary ))->$ m () : "<p> {$ ary ['main ' ]}</p> " ;
3038 $ this ->out = [...$ ctx ->out , ...$ ary , 'main ' => $ main ];
3139 }
32-
3340 public function __toString (): string {
3441 return match ($ this ->ctx ->in ['x ' ]) {
3542 'json ' => (header ('Content-Type: application/json ' ) ? '' : '' ) . json_encode ($ this ->out ),
36- default => (new Theme ($ this ->ctx , $ this ->out ))->{ $ this -> ctx -> in [ ' t ' ]} ()
43+ default => (new Theme ($ this ->ctx , $ this ->out ))->render ()
3744 };
3845 }
3946}
4047
4148abstract class Plugin {
4249 public function __construct (protected Ctx $ ctx ) {}
4350 public function create (): array { return ['head ' => 'Create ' , 'main ' => 'Not implemented ' ]; }
44- public function read (): array { return ['head ' => 'Read ' , 'main ' => 'Not implemented ' ]; }
51+ public function read (): array { return ['head ' => 'Read ' , 'main ' => 'Not implemented ' ]; }
4552 public function update (): array { return ['head ' => 'Update ' , 'main ' => 'Not implemented ' ]; }
4653 public function delete (): array { return ['head ' => 'Delete ' , 'main ' => 'Not implemented ' ]; }
47- public function list (): array { return ['head ' => 'List ' , 'main ' => 'Not implemented ' ]; }
54+ public function list (): array { return ['head ' => 'List ' , 'main ' => 'Not implemented ' ]; }
4855}
4956
5057final class HomeModel extends Plugin {
5158 #[\Override] public function list (): array {
52- return ['head ' => 'Theme Layouts ' , 'main ' => 'This chapter introduces <b>Model/View separation </b> and three switchable theme layouts . ' ];
59+ return ['head ' => 'App Shell ' , 'main ' => 'Dual sidebar interface with <b>LHS navigation </b> and <b>RHS settings</b> . ' ];
5360 }
5461}
55-
5662final class AboutModel extends Plugin {
5763 #[\Override] public function list (): array {
58- return ['head ' => 'About Page ' , 'main ' => 'This chapter adds <b>Model/View separation </b> and switchable theme layouts . ' ];
64+ return ['head ' => 'About ' , 'main ' => 'This chapter introduces the <b>App Shell </b> pattern with pinnable sidebars . ' ];
5965 }
6066}
61-
6267final class ContactModel extends Plugin {
6368 #[\Override] public function list (): array {
64- return ['head ' => 'Contact Page ' , 'main ' => 'Get in touch using the <b>email form</b> below. ' ];
69+ return ['head ' => 'Contact ' , 'main ' => 'Get in touch using the <b>email form</b> below. ' ];
6570 }
6671}
6772
6873class View {
6974 public function __construct (protected Ctx $ ctx , protected array $ ary ) {}
70-
7175 public function list (): string {
72- return <<<HTML
73- <div class="card">
74- <h2> {$ this ->ary ['head ' ]}</h2>
75- <p> {$ this ->ary ['main ' ]}</p>
76- </div>
77- HTML ;
76+ return "<div class= \"card \"><h2> {$ this ->ary ['head ' ]}</h2><p> {$ this ->ary ['main ' ]}</p></div> " ;
7877 }
7978}
8079
8180final class HomeView extends View {
8281 #[\Override] public function list (): string {
8382 return <<<HTML
84- <div class="card">
85- <h2> {$ this ->ary ['head ' ]}</h2>
86- <p> {$ this ->ary ['main ' ]}</p>
87-
88- <h3 class="mt-4">🎨 Simple</h3>
89- <p>A clean, centered layout with the navigation in a card below the header. Best for simple sites with minimal navigation. The header, nav card, content, and footer stack vertically in a single column container.</p>
90-
91- <h3 class="mt-4">🎨 TopNav</h3>
92- <p>A fixed navigation bar at the top of the viewport with centered navigation links and a theme dropdown. The content area has extra top margin to account for the fixed navbar. Ideal for sites that need persistent navigation while scrolling.</p>
93-
94- <h3 class="mt-4">🎨 SideBar</h3>
95- <p>A two-column layout with a collapsible sidebar. Click the « toggle at the bottom to collapse to icons-only mode with hover tooltips (state persists via localStorage). Groups can be collapsed by clicking their titles. On mobile, the ☰ button toggles sidebar visibility. Best for admin dashboards and applications with many navigation items.</p>
96-
97- <h3 class="mt-4">What's New in This Chapter?</h3>
98- <ul class="mt-2" style="list-style:disc;padding-left:1.5rem">
99- <li><b>Model/View Separation</b> — Models return data arrays, Views render HTML from that data</li>
100- <li><b>Theme Class</b> — A single Theme class with methods for each layout (Simple, TopNav, SideBar)</li>
101- <li><b>URL Parameter</b> — Use <code>?t=</code> to switch themes: <a href="?t=Simple">Simple</a>, <a href="?t=TopNav">TopNav</a>, <a href="?t=SideBar">SideBar</a></li>
102- <li><b>Dropdown Component</b> — Reusable dropdown menu for theme selection</li>
103- </ul>
104- </div>
105- <div class="flex justify-center mt-4">
106- <button class="btn-hover btn-success" onclick="showToast('Success!', 'success')">Success</button>
107- <button class="btn-hover btn-danger" onclick="showToast('Error!', 'danger')">Danger</button>
108- </div>
109- HTML ;
83+ <div class="card">
84+ <h2> {$ this ->ary ['head ' ]}</h2>
85+ <p> {$ this ->ary ['main ' ]}</p>
86+ <h3>Features</h3>
87+ <ul style="list-style:disc;padding-left:1.5rem;margin-top:0.5rem">
88+ <li><b>LHS Sidebar</b> - Navigation (pages, posts)</li>
89+ <li><b>RHS Sidebar</b> - Settings (theme, colors)</li>
90+ <li><b>Flyover</b> - Sidebars overlay content by default</li>
91+ <li><b>Pin</b> - Lock sidebars open on desktop (1280px+)</li>
92+ </ul>
93+ <h3>Breakpoints</h3>
94+ <ul style="list-style:disc;padding-left:1.5rem;margin-top:0.5rem">
95+ <li><b>Mobile</b> (0-767px) - Full width content</li>
96+ <li><b>Tablet</b> (768-1279px) - 1rem side padding</li>
97+ <li><b>Desktop</b> (1280px+) - 2rem padding, pin enabled</li>
98+ </ul>
99+ </div>
100+ HTML ;
110101 }
111102}
112103
@@ -115,173 +106,103 @@ final class AboutView extends View {}
115106final class ContactView extends View {
116107 #[\Override] public function list (): string {
117108 return <<<HTML
118- <div class="card">
119- <h2> {$ this ->ary ['head ' ]}</h2>
120- <p> {$ this ->ary ['main ' ]}</p>
121- <form class="mt-2" onsubmit="return handleContact(this)">
122- <div class="form-group"><label for="subject">Subject</label><input type="text" id="subject" name="subject" required></div>
123- <div class="form-group"><label for="message">Message</label><textarea id="message" name="message" rows="4" required></textarea></div>
124- <div class="text-right"><button type="submit" class="btn">Send Message</button></div>
125- </form>
126- </div>
127- <script>
128- function handleContact(form) {
129- location.href = 'mailto: {$ this ->ctx ->email }?subject=' + encodeURIComponent(form.subject.value) + '&body=' + encodeURIComponent(form.message.value);
130- showToast('Opening email client...', 'success');
131- return false;
132- }
133- </script>
134- HTML ;
109+ <div class="card">
110+ <h2> {$ this ->ary ['head ' ]}</h2>
111+ <p> {$ this ->ary ['main ' ]}</p>
112+ <form class="mt-2" onsubmit="return handleContact(this)">
113+ <div class="form-group"><label for="subject">Subject</label><input type="text" id="subject" name="subject" required></div>
114+ <div class="form-group"><label for="message">Message</label><textarea id="message" name="message" rows="4" required></textarea></div>
115+ <div class="text-right"><button type="submit" class="btn">Send Message</button></div>
116+ </form>
117+ </div>
118+ <script>
119+ function handleContact(form) {
120+ location.href = 'mailto: {$ this ->ctx ->email }?subject=' + encodeURIComponent(form.subject.value) + '&body=' + encodeURIComponent(form.message.value);
121+ showToast('Opening email client...', 'success');
122+ return false;
123+ }
124+ </script>
125+ HTML ;
135126 }
136127}
137128
138129final class Theme {
139130 public function __construct (private Ctx $ ctx , private array $ out ) {}
140131
141- private function nav (): string {
142- ['o ' => $ o , 't ' => $ t ] = $ this ->ctx ->in ;
143- return $ this ->ctx ->nav
144- |> (static fn ($ n ) => array_map (static fn ($ p ) => sprintf (
145- '<a href="?o=%s&t=%s"%s>%s</a> ' ,
146- $ p [1 ], $ t , $ o === $ p [1 ] ? ' class="active" ' : '' , $ p [0 ]
147- ), $ n ))
148- |> (static fn ($ a ) => implode (' ' , $ a ));
132+ public function render (): string {
133+ $ body = $ this ->topnav () . $ this ->sidebar ('left ' ) . $ this ->sidebar ('right ' ) . $ this ->main ();
134+ return $ this ->html ($ body );
135+ }
136+
137+ private function links (array $ items , string $ param , string $ current ): string {
138+ $ o = $ this ->ctx ->in ['o ' ];
139+ return implode ('' , array_map (fn ($ p ) => sprintf (
140+ '<a href="?o=%s"%s data-icon="%s"><i data-lucide="%s"></i> %s</a> ' ,
141+ $ param === 'o ' ? $ p [2 ] : $ o , $ current === $ p [2 ] ? ' class="active" ' : '' , $ p [0 ], $ p [0 ], $ p [1 ]
142+ ), $ items ));
149143 }
150144
151- private function dropdown (): string {
152- ['o ' => $ o , 't ' => $ t ] = $ this ->ctx ->in ;
153- $ links = $ this ->ctx ->themes
154- |> (static fn ($ n ) => array_map (static fn ($ p ) => sprintf (
155- '<a href="?o=%s&t=%s"%s>%s</a> ' ,
156- $ o , $ p [1 ], $ t === $ p [1 ] ? ' class="active" ' : '' , $ p [0 ]
157- ), $ n ))
158- |> (static fn ($ a ) => implode ('' , $ a ));
159- return "<div class= \"dropdown \"><span class= \"dropdown-toggle \">📐 Layout</span><div class= \"dropdown-menu \"> {$ links }</div></div> " ;
145+ private function colorLinks (): string {
146+ return implode ('' , array_map (fn ($ p ) => sprintf (
147+ '<a href="#" data-scheme="%s" data-icon="%s"><i data-lucide="%s"></i> %s</a> ' ,
148+ $ p [2 ], $ p [0 ], $ p [0 ], $ p [1 ]
149+ ), $ this ->ctx ->colors ));
160150 }
161151
162- private function colors (): string {
152+ private function topnav (): string {
163153 return <<<HTML
164- <div class="dropdown"><span class="dropdown-toggle">🌈 Colors</span><div class="dropdown-menu">
165- <a href="#" data-scheme="default">🪨 Stone</a>
166- <a href="#" data-scheme="ocean">🌊 Ocean</a>
167- <a href="#" data-scheme="forest">🌲 Forest</a>
168- <a href="#" data-scheme="sunset">🌅 Sunset</a>
169- </div></div>
154+ <nav class="topnav">
155+ <button class="menu-toggle" data-sidebar="left"><i data-lucide="menu"></i></button>
156+ <h1><a class="brand" href="/"><span> {$ this ->out ['doc ' ]}</span></a></h1>
157+ <button class="menu-toggle" data-sidebar="right"><i data-lucide="settings"></i></button>
158+ </nav>
170159HTML ;
171160 }
172161
173- private function html (string $ theme , string $ body ): string {
162+ private function sidebar (string $ side ): string {
163+ $ nav = $ side === 'left '
164+ ? $ this ->links ($ this ->ctx ->nav , 'o ' , $ this ->ctx ->in ['o ' ])
165+ : '<a href="#" onclick="Base.toggleTheme();return false" data-icon="moon"><i data-lucide="moon"></i> Toggle Theme</a> '
166+ . '<div class="sidebar-divider"></div> ' . $ this ->colorLinks ();
167+ $ title = $ side === 'left ' ? 'Navigation ' : 'Settings ' ;
168+ $ icon = $ side === 'left ' ? 'compass ' : 'sliders-horizontal ' ;
169+ return <<<HTML
170+ <aside class="sidebar sidebar- {$ side }">
171+ <div class="sidebar-header">
172+ <span><i data-lucide=" {$ icon }"></i> {$ title }</span>
173+ <button class="pin-toggle" data-sidebar=" {$ side }" title="Pin sidebar"><i data-lucide="pin"></i></button>
174+ </div>
175+ <nav> {$ nav }</nav>
176+ </aside>
177+ HTML ;
178+ }
179+
180+ private function main (): string {
181+ return "<main> {$ this ->out ['main ' ]}</main> " ;
182+ }
183+
184+ private function html (string $ body ): string {
185+ $ doc = $ this ->out ['doc ' ];
174186 return <<<HTML
175187<!DOCTYPE html>
176188<html lang="en">
177189<head>
178190 <meta charset="utf-8">
179191 <meta name="viewport" content="width=device-width, initial-scale=1">
180- <title> {$ this -> out [ ' doc ' ]} [ { $ theme } ] </title>
192+ <title> {$ doc} </title>
181193 <link rel="stylesheet" href="/base.css">
182194 <link rel="stylesheet" href="/site.css">
183- <script>(function(){const t=localStorage.getItem("base-theme"),s=localStorage.getItem("base-scheme"),c=t||(matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light");document.documentElement.className=c+(s&&s!=="default"?" scheme-"+s:"")})();</script>
195+ <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
196+ <script>(function(){var t=localStorage.getItem('theme'),c=localStorage.getItem('scheme'),h=document.documentElement;h.className=(t||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'))+(c&&c!=='default'?' scheme-'+c:'');})()</script>
184197</head>
185198<body>
186199{$ body }
200+ <div class="overlay"></div>
187201<script src="/base.js"></script>
188202</body>
189203</html>
190204HTML ;
191205 }
192-
193- public function Simple (): string {
194- $ nav = $ this ->nav ();
195- $ dd = $ this ->dropdown ();
196- $ colors = $ this ->colors ();
197- $ body = <<<HTML
198- <div class="container">
199- <header class="mt-4"><h1><a class="brand" href="/">‹ <span>Themes PHP Example</span></a></h1></header>
200- <nav class="card flex">
201- {$ nav } {$ dd } {$ colors }
202- <span class="ml-auto"><button class="theme-toggle" id="theme-icon">🌙</button></span>
203- </nav>
204- <main class="mt-4 mb-4"> {$ this ->out ['main ' ]}</main>
205- <footer class="text-center"><small>© 2015-2026 Mark Constable (MIT License)</small></footer>
206- </div>
207- HTML ;
208- return $ this ->html ('Simple ' , $ body );
209- }
210-
211- public function TopNav (): string {
212- $ nav = $ this ->nav ();
213- $ dd = $ this ->dropdown ();
214- $ colors = $ this ->colors ();
215- $ body = <<<HTML
216- <nav class="topnav">
217- <h1><a class="brand" href="/">‹ <span>Themes PHP Example</span></a></h1>
218- <div class="topnav-links"> {$ nav } {$ dd } {$ colors }</div>
219- <button class="theme-toggle" id="theme-icon">🌙</button>
220- <button class="menu-toggle">☰</button>
221- </nav>
222- <div class="container">
223- <main class="mt-4 mb-4"> {$ this ->out ['main ' ]}</main>
224- <footer class="text-center"><small>© 2015-2026 Mark Constable (MIT License)</small></footer>
225- </div>
226- HTML ;
227- return $ this ->html ('TopNav ' , $ body );
228- }
229-
230- public function SideBar (): string {
231- ['o ' => $ o , 't ' => $ t ] = $ this ->ctx ->in ;
232- $ n1 = $ this ->ctx ->nav
233- |> (static fn ($ n ) => array_map (static function ($ p ) use ($ o , $ t ) {
234- [$ icon , $ label ] = explode (' ' , $ p [0 ], 2 );
235- return sprintf (
236- '<a href="?o=%s&t=%s"%s title="%s" data-icon="%s">%s</a> ' ,
237- $ p [1 ], $ t , $ o === $ p [1 ] ? ' class="active" ' : '' , $ label , $ icon , $ p [0 ]
238- );
239- }, $ n ))
240- |> (static fn ($ a ) => implode ('' , $ a ));
241- $ n2 = $ this ->ctx ->themes
242- |> (static fn ($ n ) => array_map (static function ($ p ) use ($ o , $ t ) {
243- [$ icon , $ label ] = explode (' ' , $ p [0 ], 2 );
244- return sprintf (
245- '<a href="?o=%s&t=%s"%s title="%s" data-icon="%s">%s</a> ' ,
246- $ o , $ p [1 ], $ t === $ p [1 ] ? ' class="active" ' : '' , $ label , $ icon , $ p [0 ]
247- );
248- }, $ n ))
249- |> (static fn ($ a ) => implode ('' , $ a ));
250- $ body = <<<HTML
251- <nav class="topnav">
252- <button class="menu-toggle">☰</button>
253- <h1><a class="brand" href="/">‹ <span>Themes PHP Example</span></a></h1>
254- <button class="theme-toggle" id="theme-icon">🌙</button>
255- </nav>
256- <div class="sidebar-layout">
257- <aside class="sidebar">
258- <div class="sidebar-group">
259- <div class="sidebar-group-title" data-icon="📄">Pages</div>
260- <nav> {$ n1 }</nav>
261- </div>
262- <div class="sidebar-group">
263- <div class="sidebar-group-title" data-icon="📐">Layout</div>
264- <nav> {$ n2 }</nav>
265- </div>
266- <div class="sidebar-group">
267- <div class="sidebar-group-title" data-icon="🌈">Colors</div>
268- <nav>
269- <a href="#" data-scheme="default" title="Stone" data-icon="🪨">🪨 Stone</a>
270- <a href="#" data-scheme="ocean" title="Ocean" data-icon="🌊">🌊 Ocean</a>
271- <a href="#" data-scheme="forest" title="Forest" data-icon="🌲">🌲 Forest</a>
272- <a href="#" data-scheme="sunset" title="Sunset" data-icon="🌅">🌅 Sunset</a>
273- </nav>
274- </div>
275- <button class="sidebar-toggle" aria-label="Toggle sidebar"></button>
276- </aside>
277- <div class="sidebar-main">
278- <main class="mt-4 mb-4"> {$ this ->out ['main ' ]}</main>
279- <footer class="text-center"><small>© 2015-2026 Mark Constable (MIT License)</small></footer>
280- </div>
281- </div>
282- HTML ;
283- return $ this ->html ('SideBar ' , $ body );
284- }
285206}
286207}
287208
0 commit comments