Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/MetaAdmin/Wrapper.pm

O Operator
Diff

/var/www/vhosts/3dshawn.com/admin.3dshawn.com/MODS/MetaAdmin/Wrapper.pm

added on local at 2026-07-11 08:10:03

Added
+0
lines
Removed
-0
lines
Context
326
unchanged
Blobs
from e636244547a9
to e636244547a9
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11package MODS::MetaAdmin::Wrapper;
22#======================================================================
33# Meta-Admin -- page wrapper.
44#
55# HTML structure is a 1:1 port of PTMatrix's tf_wrapper.html (.app-shell
66# / .sidebar / .app-main / .topbar / .app-content) so every class name
77# matches the sister site. Colors come from the neon palette via meta.css
88# (also a port of PTMatrix's site.css with red->neon substitutions).
99#
1010# No MODS::Template dep -- emits HTML inline so the meta-admin stays a
1111# clean self-contained app.
1212#======================================================================
1313use strict;
1414use warnings;
1515
1616use MODS::MetaAdmin::Config;
1717my $cfg = MODS::MetaAdmin::Config->new;
1818
1919# Sidebar nav: array of items. {section} entries are headers.
2020my @SIDEBAR = (
2121 { section => 'Portfolio' },
2222 { key => 'home', label => 'Overview', href => '/index.cgi',
2323 svg => '<path d="M3 12L12 3l9 9"/><path d="M5 10v10h14V10"/>' },
2424 { key => 'revenue', label => 'Revenue', href => '/revenue.cgi',
2525 svg => '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7H14a3.5 3.5 0 0 1 0 7H6"/>' },
2626 { key => 'visitors', label => 'Traffic Reports', href => '/visitors.cgi',
2727 svg => '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>' },
2828 { key => 'support', label => 'Support', href => '/support.cgi',
2929 svg => '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' },
3030 { key => 'canned', label => 'Canned Replies', href => '/canned.cgi',
3131 svg => '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/>' },
3232 { key => 'auto_ack', label => 'Auto-Ack', href => '/auto_ack.cgi',
3333 svg => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>' },
3434 { key => 'server', label => 'Server', href => '/server.cgi',
3535 svg => '<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>' },
3636
3737 { section => 'Configuration' },
3838 { key => 'sites', label => 'Sites', href => '/sites.cgi',
3939 svg => '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>' },
4040 { key => 'sessions', label => 'Sessions', href => '/sessions.cgi',
4141 svg => '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><circle cx="7" cy="6" r="0.5"/><path d="M9 14h6"/>' },
4242 { key => 'maintenance', label => 'Maintenance', href => '/admin_maintenance.cgi',
4343 svg => '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
4444 { key => 'settings', label => 'Configuration', href => '/settings.cgi',
4545 svg => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>' },
4646);
4747
4848sub new { return bless({}, shift); }
4949
5050sub _esc {
5151 my $s = shift; $s //= '';
5252 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
5353 $s =~ s/"/&quot;/g;
5454 return $s;
5555}
5656
5757sub _initials {
5858 my $s = shift; $s //= '';
5959 my @p = split /[\s\@]+/, $s, 3;
6060 my $i = '';
6161 $i .= uc(substr($p[0], 0, 1)) if defined $p[0] && length $p[0];
6262 $i .= uc(substr($p[1], 0, 1)) if defined $p[1] && length $p[1];
6363 $i = uc(substr($s, 0, 1)) unless length $i;
6464 return $i;
6565}
6666
6767sub _svg_icon {
6868 my ($path) = @_;
6969 return qq~<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">$path</svg>~;
7070}
7171
7272sub _build_sidebar_nav {
7373 my ($page_key) = @_;
7474 my $html = '';
7575 foreach my $row (@SIDEBAR) {
7676 if (exists $row->{section}) {
7777 my $s = _esc($row->{section});
7878 $html .= qq~<div class="sidebar-section-label">$s</div>~;
7979 } else {
8080 my $active = ($row->{key} && $page_key && $row->{key} eq $page_key) ? ' active' : '';
8181 my $href = _esc($row->{href});
8282 my $lbl = _esc($row->{label});
8383 my $svg = _svg_icon($row->{svg} || '');
8484 $html .= qq~<a href="$href" class="sidebar-link$active">$svg<span>$lbl</span></a>~;
8585 }
8686 }
8787 # Bottom Sign out matches the right avatar dropdown — same items, both
8888 # menus end with logout.
8989 my $logout_svg = _svg_icon('<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>');
9090 $html .= qq~<div class="sidebar-section-label">Account</div>~;
9191 $html .= qq~<a href="/logout.cgi" class="sidebar-link sidebar-link-logout">$logout_svg<span>Sign out</span></a>~;
9292 return $html;
9393}
9494
9595# Build the right avatar dropdown from the SAME @SIDEBAR array so both
9696# menus stay in sync. Mirrors PTMatrix's pattern.
9797sub _build_dropdown_links {
9898 my $html = '';
9999 my $pending_section;
100100 my $emitted_first = 0;
101101 foreach my $row (@SIDEBAR) {
102102 if (exists $row->{section}) {
103103 $pending_section = $row->{section};
104104 next;
105105 }
106106 if (defined $pending_section) {
107107 $html .= qq~<div class="profile-dropdown-divider"></div>~ if $emitted_first;
108108 my $s = _esc($pending_section);
109109 $html .= qq~<div class="profile-dropdown-section">$s</div>~;
110110 $emitted_first = 1;
111111 $pending_section = undef;
112112 }
113113 my $href = _esc($row->{href});
114114 my $lbl = _esc($row->{label});
115115 my $svg = _svg_icon($row->{svg} || '');
116116 $html .= qq~<a href="$href"><span class="dropdown-icon">$svg</span> $lbl</a>~;
117117 }
118118 # Account section + Sign out at the bottom — matches the sidebar's footer.
119119 my $logout_svg = _svg_icon('<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>');
120120 $html .= qq~<div class="profile-dropdown-divider"></div>~;
121121 $html .= qq~<div class="profile-dropdown-section">Account</div>~;
122122 $html .= qq~<a href="/logout.cgi"><span class="dropdown-icon">$logout_svg</span> Sign out</a>~;
123123 return $html;
124124}
125125
126126sub render {
127127 my ($self, %a) = @_;
128128 my $title = $a{title} || 'Meta-Admin';
129129 my $page_key = $a{page_key} || '';
130130 my $body = $a{body} || '';
131131 my $userinfo = $a{userinfo};
132132 my $extra_head = $a{extra_head} || '';
133133 my $extra_css = $a{extra_css} || '';
134134 my $extra_js = $a{extra_js} || '';
135135
136136 my $brand_name = _esc($cfg->settings('brand_name') || 'Meta-Admin');
137137 my $brand_tagline = _esc($cfg->settings('brand_tagline') || 'One console. Every site.');
138138 my $brand_sub = 'Operator console';
139139 my $assets_css = $cfg->settings('assets_css') || '/assets/css';
140140
141141 my $title_h = _esc($title);
142142 my $page_h = _esc($title);
143143 my $sidebar_nav = _build_sidebar_nav($page_key);
144144 my $dropdown_nav = _build_dropdown_links();
145145
146146 my ($dn, $em, $init, $sub, $role);
147147 if ($userinfo && $userinfo->{user_id}) {
148148 my $name = $userinfo->{display_name} || $userinfo->{email} || 'Operator';
149149 $dn = _esc($name) . ' <span style="color:var(--col-text-dim);font-weight:500">(Super-admin)</span>';
150150 $em = _esc($userinfo->{email} || '');
151151 $init = _esc(_initials($name));
152152 # PTMatrix uses workspace name as the subtitle. For meta-admin
153153 # the analog is the console hostname.
154154 $sub = 'Portfolio Console';
155155 $role = 'Super-Admin';
156156 }
157157
158158 print "Content-Type: text/html; charset=utf-8\n";
159159 print "Cache-Control: no-store, no-cache, must-revalidate, max-age=0\n";
160160 print "Pragma: no-cache\nExpires: 0\n\n";
161161
162162 print <<"HTML";
163163<!doctype html>
164164<html lang="en">
165165<head>
166166<meta charset="utf-8">
167167<meta name="viewport" content="width=device-width, initial-scale=1">
168168<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
169169<meta http-equiv="Pragma" content="no-cache">
170170<title>$title_h &middot; $brand_name</title>
171171<link rel="stylesheet" href="$assets_css/meta.css?v=55">
172172$extra_css
173173$extra_head
174174<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='0.75' y='0.75' width='30.5' height='30.5' rx='7.5' fill='%232d1810' stroke='white' stroke-width='1.5'/><text x='16' y='22' font-family='Arial,Helvetica,sans-serif' font-size='14' font-weight='700' fill='white' text-anchor='middle'>MT</text></svg>">
175175</head>
176176<body>
177177<div class="app-shell">
178178 <div class="sidebar-backdrop" id="sidebarBackdrop"></div>
179179 <aside class="sidebar">
180180 <a class="sidebar-brand" href="/index.cgi">
181181 <div class="logo">MT</div>
182182 <div class="brand-text">
183183 <div class="brand-name">$brand_name</div>
184184 <div class="brand-sub">$brand_sub</div>
185185 </div>
186186 </a>
187187 $sidebar_nav
188188 </aside>
189189 <div class="app-main">
190190 <header class="topbar">
191191 <button type="button" class="topbar-iconbtn" id="sidebarToggle" title="Collapse sidebar">
192192 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
193193 </button>
194194 <div class="topbar-title">$page_h</div>
195195 <div class="topbar-spacer"></div>
196196 <form class="topbar-search" method="GET" action="/search.cgi">
197197 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="20" y1="20" x2="16.65" y2="16.65"/></svg>
198198 <input type="text" name="q" placeholder="Search sites, settings..." autocomplete="off">
199199 </form>
200200 <div class="topbar-actions">
201201HTML
202202
203203 if ($userinfo && $userinfo->{user_id}) {
204204 print <<"HTML";
205205 <div class="topbar-help-menu" tabindex="0">
206206 <button type="button" class="topbar-pill tp-help" title="Help">
207207 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h0"/></svg>
208208 <span>Help</span>
209209 <svg class="tp-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><polyline points="6 9 12 15 18 9"/></svg>
210210 </button>
211211 <div class="topbar-help-dropdown" role="menu">
212212 <div class="thd-section">Resources</div>
213213 <a href="/sites.cgi" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/></svg> Connected sites</a>
214214 <a href="/settings.cgi" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> Settings</a>
215215 <div class="thd-section">Talk to us</div>
216216 <a href="/support.cgi" role="menuitem" class="thd-primary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg> Portfolio support roll-up</a>
217217 </div>
218218 </div>
219219 <a href="/support.cgi" class="topbar-pill tp-support-queue" title="Support roll-up across portfolio">
220220 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
221221 <span>Support queue</span>
222222 </a>
223223 <a href="#" class="topbar-iconbtn" title="Notifications">
224224 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
225225 </a>
226226 <div class="profile-dropdown" id="profileDropdown">
227227 <button type="button" class="profile-btn" onclick="document.getElementById('profileMenu').classList.toggle('open');">
228228 <div class="avatar">$init</div>
229229 <div style="text-align:left;line-height:1.2">
230230 <div style="font-size:13px;font-weight:600">$dn</div>
231231 <div style="font-size:11px;color:var(--col-text-dim)">$sub</div>
232232 </div>
233233 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="6 9 12 15 18 9"/></svg>
234234 </button>
235235 <div class="profile-dropdown-menu" id="profileMenu">
236236 <div style="padding:12px 12px 10px;border-bottom:1px solid var(--col-border);margin-bottom:6px">
237237 <div style="font-weight:600;font-size:13.5px">$dn</div>
238238 <div style="font-size:11.5px;color:var(--col-text-muted)">$em</div>
239239 <div style="margin-top:6px;display:flex;gap:6px"><span class="badge primary">$role</span></div>
240240 </div>
241241 $dropdown_nav
242242 </div>
243243 </div>
244244HTML
245245 }
246246
247247 print <<"HTML";
248248 </div>
249249 </header>
250250 <main class="app-content">
251251 $body
252252 </main>
253253 </div>
254254</div>
255255<script>
256256(function(){
257257 // Sidebar collapse for desktop (icons-only); persisted in localStorage.
258258 var btn = document.getElementById('sidebarToggle');
259259 var KEY = 'meta-sidebar-collapsed';
260260 try { if (localStorage.getItem(KEY) === '1') document.documentElement.classList.add('sidebar-collapsed'); } catch(e){}
261261 if (btn) {
262262 btn.addEventListener('click', function(){
263263 document.documentElement.classList.toggle('sidebar-collapsed');
264264 try { localStorage.setItem(KEY, document.documentElement.classList.contains('sidebar-collapsed') ? '1' : '0'); } catch(e){}
265265 });
266266 }
267267 // Profile menu close-on-outside
268268 document.addEventListener('click', function(e){
269269 var m = document.getElementById('profileMenu');
270270 var d = document.getElementById('profileDropdown');
271271 if (m && d && !d.contains(e.target)) m.classList.remove('open');
272272 });
273273})();
274274
275275/*
276276 * Viewer-local timestamp renderer (portfolio-canonical).
277277 * Server emits `<span class="ts" data-ts="EPOCH" data-fmt="…">FALLBACK</span>`.
278278 * Formats: datetime | date | date-short | time | relative
279279 * Original text is the no-JS fallback. Runs once at load; re-run via
280280 * window.tzLocalize() after AJAX inserts. Full tz-aware Date.toString()
281281 * hangs off the element's title attribute for hover verification.
282282 */
283283(function(){
284284 function fmt(el){
285285 var e = parseInt(el.getAttribute('data-ts'), 10);
286286 if (!e) return;
287287 var d = new Date(e * 1000);
288288 var kind = el.getAttribute('data-fmt') || 'datetime';
289289 var out;
290290 switch (kind) {
291291 case 'date': out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'}); break;
292292 case 'date-short': out = d.toLocaleDateString(undefined, {month:'short',day:'numeric'}); break;
293293 case 'time': out = d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'}); break;
294294 case 'relative': out = rel(e); break;
295295 default: out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})
296296 + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'});
297297 }
298298 el.textContent = out;
299299 if (!el.title) el.title = d.toString();
300300 }
301301 function rel(e){
302302 var s = Math.floor(Date.now()/1000) - e, future = s < 0; s = Math.abs(s);
303303 var n, u;
304304 if (s < 60) { n = s; u = 'second'; }
305305 else if (s < 3600) { n = Math.floor(s/60); u = 'minute'; }
306306 else if (s < 86400) { n = Math.floor(s/3600); u = 'hour'; }
307307 else if (s < 2592000){ n = Math.floor(s/86400); u = 'day'; }
308308 else if (s < 31536000){n = Math.floor(s/2592000); u = 'month'; }
309309 else { n = Math.floor(s/31536000);u = 'year'; }
310310 var p = n === 1 ? '' : 's';
311311 return future ? ('in ' + n + ' ' + u + p) : (n + ' ' + u + p + ' ago');
312312 }
313313 function localize(root){ (root||document).querySelectorAll('span.ts[data-ts], time[data-ts]').forEach(fmt); }
314314 window.tzLocalize = localize;
315315 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ localize(); });
316316 else localize();
317317 window.addEventListener('ts-refresh', function(){ localize(); });
318318})();
319319</script>
320320$extra_js
321321</body>
322322</html>
323323HTML
324324}
325325
3263261;