Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/TEMPLATES/tf_wrapper.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/TEMPLATES/tf_wrapper.html

added on local at 2026-07-11 08:13:39

Added
+345
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 38df4e9c3a88
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!doctype html>
2<html lang="en" class="$tvars.html_class">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
7<meta http-equiv="Pragma" content="no-cache">
8<meta http-equiv="Expires" content="0">
9<title>$tvars.title &middot; $tvars.website_title</title>
10$tvars.meta_tags
11<link rel="stylesheet" href="$tvars.assets_css/site.css?v=2026-07-02-padfix">
12$tvars.extra_css
13<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='%232c0508' stroke='white' stroke-width='1.5'/><text x='16' y='22' font-family='Arial,Helvetica,sans-serif' font-size='17' font-weight='700' fill='white' text-anchor='middle'>PT</text></svg>">
14 <script src="/assets/js/fingerprint.js" defer></script>
15</head>
16<body>
17$tvars.impersonation_banner
18<div class="app-shell">
19 <div class="sidebar-backdrop" id="sidebarBackdrop"></div>
20 <aside class="sidebar">
21 <a class="sidebar-brand" href="/dashboard.cgi">
22 <div class="logo">PT</div>
23 <div class="brand-text">
24 <div class="brand-name">$tvars.brand_name</div>
25 <div class="brand-sub">Project Task Matrix</div>
26 </div>
27 </a>
28 $tvars.sidebar
29 </aside>
30 <div class="app-main">
31 <header class="topbar">
32 <button type="button" class="topbar-iconbtn" id="sidebarToggle" data-tip="Collapse the left sidebar to icons-only. Click again to expand. We remember your preference per browser.">
33 <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>
34 </button>
35 <div class="topbar-title">$tvars.title</div>
36 <div class="topbar-spacer"></div>
37 <div class="topbar-search">
38 <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>
39 <input type="text" id="globalSearch" placeholder="Search projects, tasks, people..." autocomplete="off">
40 </div>
41 <div class="topbar-actions">
42 <div class="topbar-help-menu" tabindex="0">
43 <button type="button" class="topbar-pill tp-help" data-tip="**Help**\nGuides, support, and contact options.">
44 <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>
45 <span>Help</span>
46 [if:$has_unread_support]<span class="tp-badge">$tvars.unread_support_count</span>[/if]
47 <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>
48 </button>
49 <div class="topbar-help-dropdown" role="menu">
50 <div class="thd-section">Resources</div>
51 <a href="/help.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"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h0"/></svg> Help center</a>
52 <a href="/contact.cgi" role="menuitem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> Public contact form</a>
53 <div class="thd-section">Talk to us</div>
54 <a href="/support.cgi" role="menuitem" class="thd-primary">
55 <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>
56 Contact support
57 [if:$has_unread_support]<span class="thd-badge">$tvars.unread_support_count</span>[/if]
58 </a>
59 </div>
60 </div>
61 [if:$is_admin]
62 <a href="/admin_support.cgi" class="topbar-pill tp-support-queue [if:$has_unread_support]tp-attention[/if]" data-tip="**Support queue**\nCustomer support threads waiting for a reply. Pulses when there's something to handle.">
63 <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>
64 <span>Support queue</span>
65 [if:$has_unread_support]<span class="tp-badge">$tvars.unread_support_count</span>[/if]
66 [if:$has_unread_support]<span class="tp-pulse-dot"></span>[/if]
67 </a>
68 [/if]
69 <a href="/notifications.cgi" class="topbar-iconbtn" data-tip="**Notifications**\nMentions, task assignments, status changes, due-soon reminders.\nA red dot means you have unread ones.">
70 <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>
71 [if:$has_unread_notifs]<span class="dot"></span>[/if]
72 </a>
73 <div class="profile-dropdown" id="profileDropdown">
74 <button type="button" class="profile-btn" onclick="document.getElementById('profileMenu').classList.toggle('open');">
75 <div class="avatar" style="background:$tvars.avatar_color">$tvars.initials</div>
76 <div style="text-align:left;line-height:1.2">
77 <div style="font-size:13px;font-weight:600">$tvars.display_name</div>
78 <div style="font-size:11px;color:var(--col-text-dim)">$tvars.company_name</div>
79 </div>
80 <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>
81 </button>
82 <div class="profile-dropdown-menu" id="profileMenu">
83 <div style="padding:12px 12px 10px;border-bottom:1px solid var(--col-border);margin-bottom:6px">
84 <div style="font-weight:600;font-size:13.5px">$tvars.display_name</div>
85 <div style="font-size:11.5px;color:var(--col-text-muted)">$tvars.email</div>
86 <div style="margin-top:6px;display:flex;gap:6px"><span class="badge primary">$tvars.role_label</span><span class="badge">$tvars.company_plan_tier</span></div>
87 </div>
88 $tvars.dropdown_links
89 <div class="profile-dropdown-divider"></div>
90 <a href="/logout.cgi"><span class="dropdown-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><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"/></svg></span> Sign out</a>
91 </div>
92 </div>
93 </div>
94 </header>
95 <main class="app-content">
96 [if:$is_trialing]
97 [if:$trial_tier_gentle]
98 <div class="trial-banner trial-banner-gentle">
99 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
100 <div>$tvars.trial_headline <a href="/billing.cgi">Pick a plan</a> any time to keep full access after $tvars.trial_ends_at.</div>
101 </div>
102 [/if]
103 [if:$trial_tier_amber]
104 <div class="trial-banner trial-banner-amber">
105 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
106 <div><strong>$tvars.trial_headline</strong> After that, your workspace drops to Free (3 users, 1 project). <a href="/billing.cgi">Upgrade now &rarr;</a></div>
107 </div>
108 [/if]
109 [if:$trial_tier_red]
110 <div class="trial-banner trial-banner-red">
111 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
112 <div><strong>$tvars.trial_headline</strong> Pick a plan today or your team drops to Free at $tvars.trial_ends_at. <a href="/billing.cgi">Upgrade now &rarr;</a></div>
113 </div>
114 [/if]
115 [/if]
116 [if:$needs_onboarding]
117 <div class="trial-banner" style="background:linear-gradient(135deg, rgba(139,92,246,.18), rgba(90,169,255,.10));border-color:rgba(139,92,246,.4)">
118 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
119 <div><strong>Welcome!</strong> Take 1 minute to pick what to call your $term_projects_lower and $term_tasks_lower. <a href="/onboarding.cgi">Start setup &rarr;</a> &middot; <a href="/onboarding.cgi?skip=1" style="color:var(--col-text-dim)">Skip for now</a></div>
120 </div>
121 [/if]
122 $tvars.body
123 </main>
124 </div>
125</div>
126<script>
127// Sidebar scroll-position preservation. Saves the .sidebar scroll
128// offset to localStorage on scroll, restores it on page load -- so
129// clicking a nav item doesn't dump the user back at the top of the
130// menu after the new page renders.
131(function(){
132 var sb = document.querySelector('.sidebar');
133 if (!sb) return;
134 var KEY = 'tf-sidebar-scroll';
135 try { var s = parseInt(localStorage.getItem(KEY), 10) || 0; if (s > 0) sb.scrollTop = s; } catch(e){}
136 var t = null;
137 sb.addEventListener('scroll', function(){
138 if (t) clearTimeout(t);
139 t = setTimeout(function(){ try { localStorage.setItem(KEY, sb.scrollTop); } catch(e){} }, 80);
140 }, { passive: true });
141})();
142</script>
143
144<script>
145// Sidebar collapse (desktop) / off-canvas drawer (mobile <= 760px).
146// Both modes persist across page loads via /save_pref.cgi:
147// sidebar_collapsed -- desktop icons-only mode
148// sidebar_mobile_open -- mobile drawer open
149// Server-side Wrapper.pm reads user_settings on every render and stamps
150// the same classes on <html> so the page boots in the saved state.
151(function(){
152 var btn = document.getElementById('sidebarToggle');
153 var backdrop = document.getElementById('sidebarBackdrop');
154 function isMobile(){ return window.matchMedia('(max-width: 760px)').matches; }
155 function savePref(key, val){
156 try {
157 fetch('/save_pref.cgi', {
158 method: 'POST',
159 headers:{'Content-Type':'application/x-www-form-urlencoded'},
160 body: key + '=' + val,
161 credentials: 'same-origin',
162 keepalive: true
163 });
164 } catch (e) {}
165 }
166 function setMobileOpen(open){
167 document.documentElement.classList.toggle('sidebar-open', open);
168 savePref('sidebar_mobile_open', open ? 1 : 0);
169 }
170 function closeDrawer(){ setMobileOpen(false); }
171
172 if(btn){
173 btn.addEventListener('click', function(){
174 if (isMobile()) {
175 var nowOpen = !document.documentElement.classList.contains('sidebar-open');
176 setMobileOpen(nowOpen);
177 } else {
178 document.documentElement.classList.toggle('sidebar-collapsed');
179 var v = document.documentElement.classList.contains('sidebar-collapsed') ? 1 : 0;
180 savePref('sidebar_collapsed', v);
181 }
182 });
183 }
184 if(backdrop){ backdrop.addEventListener('click', closeDrawer); }
185 // Tapping a sidebar link on mobile closes the drawer (and persists).
186 document.querySelectorAll('.sidebar a').forEach(function(a){
187 a.addEventListener('click', function(){ if (isMobile()) closeDrawer(); });
188 });
189 // If the viewport grows past mobile, drop the mobile-open state so the
190 // desktop collapsed/expanded toggle takes over cleanly. Don't persist
191 // here -- this is a transient viewport-change cleanup, the user didn't
192 // tap close.
193 window.addEventListener('resize', function(){
194 if (!isMobile()) document.documentElement.classList.remove('sidebar-open');
195 });
196 // Close profile menu on outside click.
197 document.addEventListener('click', function(e){
198 var m = document.getElementById('profileMenu');
199 var d = document.getElementById('profileDropdown');
200 if(m && d && !d.contains(e.target)) m.classList.remove('open');
201 });
202})();
203</script>
204<script src="$tvars.assets_js/global-search.js?v=2026-06-12-1"></script>
205<script src="$tvars.assets_js/tooltips.js?v=20260610d"></script>
206<script src="$tvars.assets_js/color-picker.js?v=20260607a"></script>
207<script src="$tvars.assets_js/attachments.js?v=20260618-record"></script>
208
209<script>
210/* Web push: register the service worker on every page load (idempotent).
211 Expose tfPush.* helpers for the prefs page to call. Permission is NOT
212 auto-requested — the user opts in from /preferences.cgi. */
213(function(){
214 if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
215 window.tfPush = { supported: false };
216 return;
217 }
218 function urlBase64ToUint8Array(s){
219 var pad = '='.repeat((4 - s.length % 4) % 4);
220 var base64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
221 var raw = atob(base64);
222 var out = new Uint8Array(raw.length);
223 for (var i=0;i<raw.length;i++) out[i] = raw.charCodeAt(i);
224 return out;
225 }
226 function form(act, extra){
227 var fd = new FormData();
228 fd.append('act', act);
229 if (extra) for (var k in extra) fd.append(k, extra[k]);
230 return fd;
231 }
232 var swReg = null;
233 navigator.serviceWorker.register('/sw.js?v=20260618-1', { scope: '/' })
234 .then(function(r){ swReg = r; })
235 .catch(function(){});
236
237 window.tfPush = {
238 supported: true,
239 permission: function(){ return Notification.permission; },
240 isSubscribed: function(){
241 if (!swReg) return Promise.resolve(false);
242 return swReg.pushManager.getSubscription().then(function(s){ return !!s; });
243 },
244 enable: async function(){
245 if (Notification.permission === 'denied') {
246 throw new Error('Notifications are blocked at the browser level. Open your browser settings to re-allow them.');
247 }
248 var perm = await Notification.requestPermission();
249 if (perm !== 'granted') throw new Error('Permission denied.');
250 if (!swReg) swReg = await navigator.serviceWorker.ready;
251 var resp = await fetch('/push_action.cgi?act=vapid_key', { credentials: 'include' });
252 var key = (await resp.json()).key;
253 if (!key) throw new Error('Server has no VAPID key configured.');
254 var sub = await swReg.pushManager.subscribe({
255 userVisibleOnly: true,
256 applicationServerKey: urlBase64ToUint8Array(key),
257 });
258 var raw = sub.toJSON();
259 var keys = raw.keys || {};
260 var save = await fetch('/push_action.cgi', {
261 method: 'POST', credentials: 'include',
262 body: form('subscribe', {
263 endpoint: raw.endpoint,
264 p256dh: keys.p256dh || '',
265 auth: keys.auth || '',
266 ua: navigator.userAgent || '',
267 }),
268 });
269 var j = await save.json();
270 if (!j.ok) throw new Error(j.err || 'Subscribe failed');
271 return true;
272 },
273 disable: async function(){
274 if (!swReg) swReg = await navigator.serviceWorker.ready;
275 var sub = await swReg.pushManager.getSubscription();
276 if (sub) {
277 try {
278 await fetch('/push_action.cgi', {
279 method: 'POST', credentials: 'include',
280 body: form('unsubscribe', { endpoint: sub.endpoint }),
281 });
282 } catch(_) {}
283 await sub.unsubscribe();
284 }
285 return true;
286 },
287 test: async function(){
288 var r = await fetch('/push_action.cgi', {
289 method: 'POST', credentials: 'include',
290 body: form('test_send', {}),
291 });
292 var j = await r.json();
293 if (!j.ok) throw new Error(j.err || 'Test send failed');
294 return true;
295 },
296 };
297})();
298</script>
299
300$tvars.extra_js
301<!-- Viewer-local timestamp renderer (portfolio-canonical).
302 Server emits `<span class="ts" data-ts="EPOCH" data-fmt="…">FALLBACK</span>`.
303 Formats: datetime | date | date-short | time | relative
304 Original text is the no-JS fallback. Re-run via window.tzLocalize() after AJAX. -->
305<script>
306(function(){
307 function fmt(el){
308 var e = parseInt(el.getAttribute('data-ts'), 10);
309 if (!e) return;
310 var d = new Date(e * 1000);
311 var kind = el.getAttribute('data-fmt') || 'datetime';
312 var out;
313 switch (kind) {
314 case 'date': out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'}); break;
315 case 'date-short': out = d.toLocaleDateString(undefined, {month:'short',day:'numeric'}); break;
316 case 'time': out = d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'}); break;
317 case 'relative': out = rel(e); break;
318 default: out = d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})
319 + ' ' + d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'});
320 }
321 el.textContent = out;
322 if (!el.title) el.title = d.toString();
323 }
324 function rel(e){
325 var s = Math.floor(Date.now()/1000) - e, future = s < 0; s = Math.abs(s);
326 var n, u;
327 if (s < 60) { n = s; u = 'second'; }
328 else if (s < 3600) { n = Math.floor(s/60); u = 'minute'; }
329 else if (s < 86400) { n = Math.floor(s/3600); u = 'hour'; }
330 else if (s < 2592000){ n = Math.floor(s/86400); u = 'day'; }
331 else if (s < 31536000){n = Math.floor(s/2592000); u = 'month'; }
332 else { n = Math.floor(s/31536000);u = 'year'; }
333 var p = n === 1 ? '' : 's';
334 return future ? ('in ' + n + ' ' + u + p) : (n + ' ' + u + p + ' ago');
335 }
336 function localize(root){ (root||document).querySelectorAll('span.ts[data-ts], time[data-ts]').forEach(fmt); }
337 window.tzLocalize = localize;
338 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ localize(); });
339 else localize();
340 window.addEventListener('ts-refresh', function(){ localize(); });
341})();
342</script>
343
344</body>
345</html>