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
+326
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
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.
1package MODS::MetaAdmin::Wrapper;
2#======================================================================
3# Meta-Admin -- page wrapper.
4#
5# HTML structure is a 1:1 port of PTMatrix's tf_wrapper.html (.app-shell
6# / .sidebar / .app-main / .topbar / .app-content) so every class name
7# matches the sister site. Colors come from the neon palette via meta.css
8# (also a port of PTMatrix's site.css with red->neon substitutions).
9#
10# No MODS::Template dep -- emits HTML inline so the meta-admin stays a
11# clean self-contained app.
12#======================================================================
13use strict;
14use warnings;
15
16use MODS::MetaAdmin::Config;
17my $cfg = MODS::MetaAdmin::Config->new;
18
19# Sidebar nav: array of items. {section} entries are headers.
20my @SIDEBAR = (
21 { section => 'Portfolio' },
22 { key => 'home', label => 'Overview', href => '/index.cgi',
23 svg => '<path d="M3 12L12 3l9 9"/><path d="M5 10v10h14V10"/>' },
24 { key => 'revenue', label => 'Revenue', href => '/revenue.cgi',
25 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"/>' },
26 { key => 'visitors', label => 'Traffic Reports', href => '/visitors.cgi',
27 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"/>' },
28 { key => 'support', label => 'Support', href => '/support.cgi',
29 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"/>' },
30 { key => 'canned', label => 'Canned Replies', href => '/canned.cgi',
31 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"/>' },
32 { key => 'auto_ack', label => 'Auto-Ack', href => '/auto_ack.cgi',
33 svg => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>' },
34 { key => 'server', label => 'Server', href => '/server.cgi',
35 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"/>' },
36
37 { section => 'Configuration' },
38 { key => 'sites', label => 'Sites', href => '/sites.cgi',
39 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"/>' },
40 { key => 'sessions', label => 'Sessions', href => '/sessions.cgi',
41 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"/>' },
42 { key => 'maintenance', label => 'Maintenance', href => '/admin_maintenance.cgi',
43 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"/>' },
44 { key => 'settings', label => 'Configuration', href => '/settings.cgi',
45 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"/>' },
46);
47
48sub new { return bless({}, shift); }
49
50sub _esc {
51 my $s = shift; $s //= '';
52 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
53 $s =~ s/"/&quot;/g;
54 return $s;
55}
56
57sub _initials {
58 my $s = shift; $s //= '';
59 my @p = split /[\s\@]+/, $s, 3;
60 my $i = '';
61 $i .= uc(substr($p[0], 0, 1)) if defined $p[0] && length $p[0];
62 $i .= uc(substr($p[1], 0, 1)) if defined $p[1] && length $p[1];
63 $i = uc(substr($s, 0, 1)) unless length $i;
64 return $i;
65}
66
67sub _svg_icon {
68 my ($path) = @_;
69 return qq~<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">$path</svg>~;
70}
71
72sub _build_sidebar_nav {
73 my ($page_key) = @_;
74 my $html = '';
75 foreach my $row (@SIDEBAR) {
76 if (exists $row->{section}) {
77 my $s = _esc($row->{section});
78 $html .= qq~<div class="sidebar-section-label">$s</div>~;
79 } else {
80 my $active = ($row->{key} && $page_key && $row->{key} eq $page_key) ? ' active' : '';
81 my $href = _esc($row->{href});
82 my $lbl = _esc($row->{label});
83 my $svg = _svg_icon($row->{svg} || '');
84 $html .= qq~<a href="$href" class="sidebar-link$active">$svg<span>$lbl</span></a>~;
85 }
86 }
87 # Bottom Sign out matches the right avatar dropdown — same items, both
88 # menus end with logout.
89 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"/>');
90 $html .= qq~<div class="sidebar-section-label">Account</div>~;
91 $html .= qq~<a href="/logout.cgi" class="sidebar-link sidebar-link-logout">$logout_svg<span>Sign out</span></a>~;
92 return $html;
93}
94
95# Build the right avatar dropdown from the SAME @SIDEBAR array so both
96# menus stay in sync. Mirrors PTMatrix's pattern.
97sub _build_dropdown_links {
98 my $html = '';
99 my $pending_section;
100 my $emitted_first = 0;
101 foreach my $row (@SIDEBAR) {
102 if (exists $row->{section}) {
103 $pending_section = $row->{section};
104 next;
105 }
106 if (defined $pending_section) {
107 $html .= qq~<div class="profile-dropdown-divider"></div>~ if $emitted_first;
108 my $s = _esc($pending_section);
109 $html .= qq~<div class="profile-dropdown-section">$s</div>~;
110 $emitted_first = 1;
111 $pending_section = undef;
112 }
113 my $href = _esc($row->{href});
114 my $lbl = _esc($row->{label});
115 my $svg = _svg_icon($row->{svg} || '');
116 $html .= qq~<a href="$href"><span class="dropdown-icon">$svg</span> $lbl</a>~;
117 }
118 # Account section + Sign out at the bottom — matches the sidebar's footer.
119 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"/>');
120 $html .= qq~<div class="profile-dropdown-divider"></div>~;
121 $html .= qq~<div class="profile-dropdown-section">Account</div>~;
122 $html .= qq~<a href="/logout.cgi"><span class="dropdown-icon">$logout_svg</span> Sign out</a>~;
123 return $html;
124}
125
126sub render {
127 my ($self, %a) = @_;
128 my $title = $a{title} || 'Meta-Admin';
129 my $page_key = $a{page_key} || '';
130 my $body = $a{body} || '';
131 my $userinfo = $a{userinfo};
132 my $extra_head = $a{extra_head} || '';
133 my $extra_css = $a{extra_css} || '';
134 my $extra_js = $a{extra_js} || '';
135
136 my $brand_name = _esc($cfg->settings('brand_name') || 'Meta-Admin');
137 my $brand_tagline = _esc($cfg->settings('brand_tagline') || 'One console. Every site.');
138 my $brand_sub = 'Operator console';
139 my $assets_css = $cfg->settings('assets_css') || '/assets/css';
140
141 my $title_h = _esc($title);
142 my $page_h = _esc($title);
143 my $sidebar_nav = _build_sidebar_nav($page_key);
144 my $dropdown_nav = _build_dropdown_links();
145
146 my ($dn, $em, $init, $sub, $role);
147 if ($userinfo && $userinfo->{user_id}) {
148 my $name = $userinfo->{display_name} || $userinfo->{email} || 'Operator';
149 $dn = _esc($name) . ' <span style="color:var(--col-text-dim);font-weight:500">(Super-admin)</span>';
150 $em = _esc($userinfo->{email} || '');
151 $init = _esc(_initials($name));
152 # PTMatrix uses workspace name as the subtitle. For meta-admin
153 # the analog is the console hostname.
154 $sub = 'Portfolio Console';
155 $role = 'Super-Admin';
156 }
157
158 print "Content-Type: text/html; charset=utf-8\n";
159 print "Cache-Control: no-store, no-cache, must-revalidate, max-age=0\n";
160 print "Pragma: no-cache\nExpires: 0\n\n";
161
162 print <<"HTML";
163<!doctype html>
164<html lang="en">
165<head>
166<meta charset="utf-8">
167<meta name="viewport" content="width=device-width, initial-scale=1">
168<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
169<meta http-equiv="Pragma" content="no-cache">
170<title>$title_h &middot; $brand_name</title>
171<link rel="stylesheet" href="$assets_css/meta.css?v=55">
172$extra_css
173$extra_head
174<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>">
175</head>
176<body>
177<div class="app-shell">
178 <div class="sidebar-backdrop" id="sidebarBackdrop"></div>
179 <aside class="sidebar">
180 <a class="sidebar-brand" href="/index.cgi">
181 <div class="logo">MT</div>
182 <div class="brand-text">
183 <div class="brand-name">$brand_name</div>
184 <div class="brand-sub">$brand_sub</div>
185 </div>
186 </a>
187 $sidebar_nav
188 </aside>
189 <div class="app-main">
190 <header class="topbar">
191 <button type="button" class="topbar-iconbtn" id="sidebarToggle" title="Collapse sidebar">
192 <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>
193 </button>
194 <div class="topbar-title">$page_h</div>
195 <div class="topbar-spacer"></div>
196 <form class="topbar-search" method="GET" action="/search.cgi">
197 <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>
198 <input type="text" name="q" placeholder="Search sites, settings..." autocomplete="off">
199 </form>
200 <div class="topbar-actions">
201HTML
202
203 if ($userinfo && $userinfo->{user_id}) {
204 print <<"HTML";
205 <div class="topbar-help-menu" tabindex="0">
206 <button type="button" class="topbar-pill tp-help" title="Help">
207 <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>
208 <span>Help</span>
209 <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>
210 </button>
211 <div class="topbar-help-dropdown" role="menu">
212 <div class="thd-section">Resources</div>
213 <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>
214 <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>
215 <div class="thd-section">Talk to us</div>
216 <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>
217 </div>
218 </div>
219 <a href="/support.cgi" class="topbar-pill tp-support-queue" title="Support roll-up across portfolio">
220 <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>
221 <span>Support queue</span>
222 </a>
223 <a href="#" class="topbar-iconbtn" title="Notifications">
224 <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>
225 </a>
226 <div class="profile-dropdown" id="profileDropdown">
227 <button type="button" class="profile-btn" onclick="document.getElementById('profileMenu').classList.toggle('open');">
228 <div class="avatar">$init</div>
229 <div style="text-align:left;line-height:1.2">
230 <div style="font-size:13px;font-weight:600">$dn</div>
231 <div style="font-size:11px;color:var(--col-text-dim)">$sub</div>
232 </div>
233 <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>
234 </button>
235 <div class="profile-dropdown-menu" id="profileMenu">
236 <div style="padding:12px 12px 10px;border-bottom:1px solid var(--col-border);margin-bottom:6px">
237 <div style="font-weight:600;font-size:13.5px">$dn</div>
238 <div style="font-size:11.5px;color:var(--col-text-muted)">$em</div>
239 <div style="margin-top:6px;display:flex;gap:6px"><span class="badge primary">$role</span></div>
240 </div>
241 $dropdown_nav
242 </div>
243 </div>
244HTML
245 }
246
247 print <<"HTML";
248 </div>
249 </header>
250 <main class="app-content">
251 $body
252 </main>
253 </div>
254</div>
255<script>
256(function(){
257 // Sidebar collapse for desktop (icons-only); persisted in localStorage.
258 var btn = document.getElementById('sidebarToggle');
259 var KEY = 'meta-sidebar-collapsed';
260 try { if (localStorage.getItem(KEY) === '1') document.documentElement.classList.add('sidebar-collapsed'); } catch(e){}
261 if (btn) {
262 btn.addEventListener('click', function(){
263 document.documentElement.classList.toggle('sidebar-collapsed');
264 try { localStorage.setItem(KEY, document.documentElement.classList.contains('sidebar-collapsed') ? '1' : '0'); } catch(e){}
265 });
266 }
267 // Profile menu close-on-outside
268 document.addEventListener('click', function(e){
269 var m = document.getElementById('profileMenu');
270 var d = document.getElementById('profileDropdown');
271 if (m && d && !d.contains(e.target)) m.classList.remove('open');
272 });
273})();
274
275/*
276 * Viewer-local timestamp renderer (portfolio-canonical).
277 * Server emits `<span class="ts" data-ts="EPOCH" data-fmt="…">FALLBACK</span>`.
278 * Formats: datetime | date | date-short | time | relative
279 * Original text is the no-JS fallback. Runs once at load; re-run via
280 * window.tzLocalize() after AJAX inserts. Full tz-aware Date.toString()
281 * hangs off the element's title attribute for hover verification.
282 */
283(function(){
284 function fmt(el){
285 var e = parseInt(el.getAttribute('data-ts'), 10);
286 if (!e) return;
287 var d = new Date(e * 1000);
288 var kind = el.getAttribute('data-fmt') || 'datetime';
289 var out;
290 switch (kind) {
291 case 'date': out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'}); break;
292 case 'date-short': out = d.toLocaleDateString(undefined, {month:'short',day:'numeric'}); break;
293 case 'time': out = d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'}); break;
294 case 'relative': out = rel(e); break;
295 default: out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})
296 + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'});
297 }
298 el.textContent = out;
299 if (!el.title) el.title = d.toString();
300 }
301 function rel(e){
302 var s = Math.floor(Date.now()/1000) - e, future = s < 0; s = Math.abs(s);
303 var n, u;
304 if (s < 60) { n = s; u = 'second'; }
305 else if (s < 3600) { n = Math.floor(s/60); u = 'minute'; }
306 else if (s < 86400) { n = Math.floor(s/3600); u = 'hour'; }
307 else if (s < 2592000){ n = Math.floor(s/86400); u = 'day'; }
308 else if (s < 31536000){n = Math.floor(s/2592000); u = 'month'; }
309 else { n = Math.floor(s/31536000);u = 'year'; }
310 var p = n === 1 ? '' : 's';
311 return future ? ('in ' + n + ' ' + u + p) : (n + ' ' + u + p + ' ago');
312 }
313 function localize(root){ (root||document).querySelectorAll('span.ts[data-ts], time[data-ts]').forEach(fmt); }
314 window.tzLocalize = localize;
315 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ localize(); });
316 else localize();
317 window.addEventListener('ts-refresh', function(){ localize(); });
318})();
319</script>
320$extra_js
321</body>
322</html>
323HTML
324}
325
3261;