Files
Project-SDE-WP-Suite/html/index.html
n.siegfried 61d1cf4bff Wave 2: form cleanups from the site comments, plus localization, project switcher and global search
Site comments (8/3)
- BIM card: LOD removed, IFF # added next to the coordination status, and required
  once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable.
  A LOD already stored on a package is preserved and shown as legacy, not blanked.
- The blue "from SOP types" subtext under a field is now a SOP chip on the label
  with the detail in a tooltip. The chip stays visible rather than hover-only:
  field tablets have no hover, and "this came from the SOP" is the part that
  matters. The hint elements stay in the DOM (hidden) so the code writing to them
  keeps working; an observer mirrors their text into the tooltip.
- Specification Section is no longer typed per package. Each WP type carries a
  spec section on the SOP; the field is read-only in the Creator and follows the
  type, with the SOP's spec folder linked underneath. This reads both spec
  comments as one intent — stop typing it, derive it.
- Assignees and Distribution are multi-selects over the SOP project team, showing
  each person's job function, with the CM pre-added to Distribution (removable per
  package) and a free-text option for people with no account. The stored display
  strings are unchanged so print/export/dashboard keep working; account ids ride
  alongside for the notification work in wave 3.

Localization + time
- Per-user locale/timezone (Language & time in the user menu), an app-wide default
  in the admin console, then the browser. Timezones are validated against the
  server's zoneinfo and the picker is fed from it. Calendar dates are formatted
  from their parts so a due date never reads a day early in another zone.
- Every displayed timestamp now goes through the shared helpers.

Top-bar chrome
- Project switcher beside the logo and a centered global search, injected into
  either generation of top bar; skipped in an iframe so the embedded Creator
  doesn't get a second one. Ctrl/Cmd-K focuses search.
- GET /api/search covers work packages, projects and SOPs, scoped to the caller's
  projects, hiding archived packages, with LIKE wildcards escaped.

Fixed along the way: showForm() cleared every card's inline display, which undid
applyKind() — so the Package Type and BIM cards reappeared on an install-only
project. Split out applyKindVisibility() and re-apply it there.

Verified: 100 API checks on a fresh database (44 permissions + 22 password reset +
34 search/localization), 24 driven UI checks against the real Creator page in
headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags,
BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:17:51 -07:00

669 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work Package Suite — Prime Controls</title>
<script src="auth-guard.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--cds-background);
color: var(--cds-text-primary);
line-height: 1.5;
}
/* CONTAINER */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2.5rem 2rem 3rem;
}
/* HERO */
.hero {
margin-bottom: 2.5rem;
}
.hero h1 {
font-size: 2.25rem;
font-weight: 300;
letter-spacing: -0.01em;
margin-bottom: 0.5rem;
color: var(--cds-text-primary);
}
.hero p {
font-size: 1rem;
color: var(--cds-text-secondary);
max-width: 760px;
}
/* CARDS */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--cds-layer);
border: 1px solid var(--cds-border-subtle);
border-left: 4px solid var(--cds-border-strong);
padding: 1.5rem;
transition: border-color 0.15s, background 0.15s;
text-decoration: none;
color: var(--cds-text-primary);
display: flex;
flex-direction: column;
}
.card:hover {
border-left-color: var(--cds-interactive-01);
background: var(--cds-layer-hover);
}
.card-badge {
display: inline-block;
background: var(--cds-button-primary);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
margin-bottom: 1rem;
width: fit-content;
}
.card h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.card p {
color: var(--cds-text-secondary);
margin-bottom: 1.5rem;
flex: 1;
font-size: 0.95rem;
}
.card-button {
display: inline-block;
align-self: flex-start;
background: var(--cds-button-primary);
color: white;
padding: 0.7rem 1.25rem;
text-decoration: none;
font-weight: 600;
text-align: center;
transition: background 0.2s;
border: none;
cursor: pointer;
font-size: 13px;
}
.card-button:hover {
background: var(--cds-hover-primary);
}
/* COMPLETE STATE (SOP done) */
.card.complete {
border-left-color: var(--cds-support-success);
}
.card.complete .card-button { background: var(--cds-support-success); }
.card.complete .card-button:hover { background: #0e6027; }
.card-status {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: var(--cds-support-success);
margin-bottom: 0.5rem;
}
.card.disabled {
opacity: 0.6;
pointer-events: none;
}
/* SECTION */
.section {
background: var(--cds-layer);
padding: 1.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--cds-border-subtle);
}
.section h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--cds-text-primary);
}
.section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.section p {
color: var(--cds-text-secondary);
margin-bottom: 1rem;
font-size: 0.95rem;
}
/* FOOTER */
.footer {
background: var(--cds-ui-01);
color: var(--cds-text-secondary);
padding: 2rem;
text-align: center;
font-size: 12px;
border-top: 1px solid var(--cds-border-subtle);
}
.footer a {
color: var(--cds-link-primary);
text-decoration: none;
}
.footer a:hover { text-decoration: underline; }
/* COMMENTS SECTION */
.comments-section {
background: var(--cds-layer);
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--cds-border-subtle);
}
.comments-toggle {
padding: 0.7rem 1.25rem;
background: var(--cds-button-primary);
color: white;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.comments-toggle:hover { background: var(--cds-hover-primary); }
.comments-panel {
display: none;
margin-top: 1rem;
padding: 1rem;
background: var(--cds-layer-accent);
border: 1px solid var(--cds-border-subtle);
}
.comments-panel.open { display: block; }
.comments-panel input,
.comments-panel textarea {
width: 100%;
padding: 0.7rem;
border: 1px solid var(--cds-border-strong);
background: var(--cds-field);
color: var(--cds-text-primary);
font-family: inherit;
margin-bottom: 1rem;
}
.comments-panel input:focus,
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
.comments-panel textarea {
resize: vertical;
min-height: 80px;
}
.comment-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-buttons button {
padding: 0.5rem 1rem;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn {
background: var(--cds-button-primary);
color: white;
}
.submit-btn:hover { background: var(--cds-hover-primary); }
.close-btn {
background: var(--cds-layer-selected);
color: var(--cds-text-primary);
border: 1px solid var(--cds-border-strong);
}
.close-btn:hover { background: var(--cds-layer-selected-hover); }
.comments-list {
margin-top: 1rem;
max-height: 300px;
overflow-y: auto;
}
.comment-item {
padding: 0.75rem;
background: var(--cds-background);
border: 1px solid var(--cds-border-subtle);
margin-bottom: 0.5rem;
font-size: 12px;
}
.comment-meta {
font-size: 11px;
color: var(--cds-text-secondary);
margin-bottom: 0.25rem;
}
.comment-text {
color: var(--cds-text-primary);
}
/* PROJECT PICKER */
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; }
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
padding: 1.25rem; }
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
/* RESPONSIVE */
@media (max-width: 768px) {
.hero h1 { font-size: 1.75rem; }
.cards-grid { grid-template-columns: 1fr; }
.container { padding: 1.5rem; }
}
</style>
</head>
<body>
<!-- HEADER -->
<header class="wp-appbar">
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
<span class="wp-appbar-title">Work Package Suite</span>
</a>
<div class="wp-appbar-spacer"></div>
<nav class="wp-appbar-actions">
<a class="wp-appbar-link" href="#overview">Overview</a>
<a class="wp-appbar-link" href="#comments">Feedback</a>
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
</nav>
</header>
<!-- MAIN CONTENT -->
<div class="container">
<!-- HERO -->
<div class="hero">
<h1 id="hero-title">Work Package Suite</h1>
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
</div>
<!-- PROJECT SELECTION -->
<div class="section" id="project-section">
<h2>Project</h2>
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
</div>
<!-- TOOL CARDS (shown once a project is active) -->
<div class="cards-grid" id="overview" style="display:none">
<!-- SOP CONFIG -->
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
<h3>SOP Configuration</h3>
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
<button class="card-button" id="card-sop-btn">Open Tool</button>
</a>
<!-- WP CREATOR -->
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
<h3>Work Package Creator</h3>
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
<button class="card-button" id="card-wp-btn">Open Tool</button>
</a>
<!-- WP DASHBOARD -->
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
<h3>Work Package Dashboard</h3>
<p>Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.</p>
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
</a>
<!-- FIELD VIEW -->
<a href="field.html" class="card" id="card-field">
<h3>Field View</h3>
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
<button class="card-button" id="card-field-btn">Open Field View</button>
</a>
</div>
<!-- COMMENTS SECTION -->
<div class="comments-section" id="comments">
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
<div class="comments-panel" id="comments-panel">
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
<input type="text" id="commenter-name" placeholder="Your name">
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Feedback</label>
<textarea id="comment-text" placeholder="Your feedback here..."></textarea>
</div>
<div class="comment-buttons">
<button class="submit-btn" onclick="submitComment()">Submit</button>
<button class="close-btn" onclick="exportFeedback()">⤓ Export</button>
<button class="close-btn" onclick="document.getElementById('feedback-import').click()">⤒ Import</button>
<button class="close-btn" onclick="toggleComments()">Close</button>
<input type="file" id="feedback-import" accept="application/json" style="display:none" onchange="importFeedback(event)">
</div>
<div class="comments-list" id="comments-list"></div>
</div>
</div>
</div>
<!-- FOOTER -->
<footer class="footer">
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
</footer>
<script src="feedback-config.js"></script>
<script src="project-data.js"></script>
<script src="help.js"></script>
<script>
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
const esc = ProjectData.esc;
let _projects = [];
function initProjects(){
ProjectData.list().then(list => {
_projects = list || [];
// Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive();
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
renderProjectPicker();
applyActiveProject();
});
}
function createFormHtml(){
return `<div class="proj-form" id="proj-form" style="display:none">
<div class="proj-form-grid">
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
</div>
<div class="proj-actions">
<button class="card-button" onclick="saveNewProject()">Create &amp; Select</button>
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
</div>
</div>`;
}
function renderProjectPicker(){
const box = document.getElementById('project-picker');
const activeId = ProjectData.getActiveId();
if(!_projects.length){
box.innerHTML = `<div class="proj-empty">
<p>No projects yet. Create your first project, or start from a sample.</p>
<div class="proj-actions">
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
</div>
</div>` + createFormHtml();
return;
}
const opts = _projects.map(p =>
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
).join('');
box.innerHTML = `<div class="proj-row">
<select id="project-select" onchange="selectProject(this.value)">
<option value="">Select a project…</option>${opts}
</select>
<button class="card-button" onclick="showCreateProject()">+ New</button>
<button class="close-btn" onclick="useSampleProject()">Sample</button>
</div>
<div id="active-project-info"></div>` + createFormHtml();
}
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
function saveNewProject(){
const v = id => (document.getElementById(id)?.value || '').trim();
const name = v('np_name');
if(!name){ alert('Project name is required.'); return; }
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
}
function useSampleProject(){
const existing = _projects.find(p => p.sample);
if(existing){ afterProjectChosen(existing); return; }
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
}
function selectProject(id){
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
const p = _projects.find(x => x.id === id);
if(p){ ProjectData.setActive(p); applyActiveProject(); }
}
function afterProjectChosen(p){
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
ProjectData.setActive(p);
renderProjectPicker();
applyActiveProject();
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
}
// Show/hide the tool cards and stamp the active project into their links.
function applyActiveProject(){
const active = ProjectData.getActive();
const cards = document.getElementById('overview');
const heroTitle = document.getElementById('hero-title');
const heroSub = document.getElementById('hero-sub');
const info = document.getElementById('active-project-info');
if(!active){
cards.style.display = 'none';
heroTitle.textContent = 'Work Package Suite';
heroSub.textContent = 'Select a project to begin — or create one.';
if(info) info.innerHTML = '';
return;
}
const q = '&project=' + encodeURIComponent(active.id);
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
setHref('card-sop', 'work-package-suite.html?tab=sop');
setHref('card-wp', 'work-package-suite.html?tab=wp');
setHref('card-dash', 'work-package-suite.html?view=dashboard');
setHref('card-field', 'field.html?src=home');
cards.style.display = '';
heroTitle.textContent = active.name || 'Work Package Suite';
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
&nbsp;<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
// Pull the project's shared SOP/WPs from the server into the local cache
// first, so the SOP "Complete / Review" status reflects what other users did.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
else reflectSOPStatus(active);
}
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
// Reflect SOP completion on the tool cards (scoped to the active project).
function reflectSOPStatus(active){
let complete = false, projName = '';
try {
// Storage is namespaced per project, so these already scope to `active`.
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
projName = sop && sop.project && sop.project.name || '';
} catch(e){}
const sopCard = document.getElementById('card-sop');
const sopBtn = document.getElementById('card-sop-btn');
const wpCard = document.getElementById('card-wp');
const wpBtn = document.getElementById('card-wp-btn');
if(!sopCard) return;
// reset (re-render can run multiple times)
sopCard.classList.remove('complete');
wpCard && wpCard.classList.remove('disabled');
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
if(complete){
sopCard.classList.add('complete');
sopBtn.textContent = 'Review';
const status = document.createElement('div');
status.className = 'card-status';
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
sopCard.insertBefore(status, sopCard.firstChild);
if(wpBtn) wpBtn.textContent = 'Open Creator';
} else {
if(wpCard) wpCard.classList.add('disabled');
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
}
}
initProjects();
let allComments = [];
function toggleComments() {
const panel = document.getElementById('comments-panel');
panel.classList.toggle('open');
if (panel.classList.contains('open')) loadComments();
}
function submitComment() {
const name = document.getElementById('commenter-name').value || 'Anonymous';
const text = document.getElementById('comment-text').value.trim();
if (!text) {
alert('Please enter feedback.');
return;
}
const comment = {
name,
text,
timestamp: new Date().toLocaleString()
};
allComments.push(comment);
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
document.getElementById('comment-text').value = '';
loadComments();
}
function exportFeedback() {
const saved = localStorage.getItem('wp_suite_index_comments');
const data = saved ? JSON.parse(saved) : [];
if (!data.length) { alert('No feedback to export yet.'); return; }
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-feedback-home-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function importFeedback(ev) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try {
const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
if (!incoming.length) { alert('No feedback found in that file.'); return; }
const saved = localStorage.getItem('wp_suite_index_comments');
allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
let added = 0;
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
loadComments();
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
} catch (e) { alert('Could not read that file.'); }
ev.target.value = '';
};
r.readAsText(f);
}
function renderComments() {
const list = document.getElementById('comments-list');
if (allComments.length === 0) {
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
} else {
list.innerHTML = allComments.map(c => `
<div class="comment-item">
<div class="comment-meta"><strong>${(c.name||'Anonymous').replace(/</g,'&lt;')}</strong> • ${c.timestamp||''}</div>
<div class="comment-text">${(c.text||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>
</div>
`).join('');
}
}
function loadComments() {
// Server is authoritative (so feedback is shared across users); fall back to
// the local cache if the API is unreachable.
const saved = localStorage.getItem('wp_suite_index_comments');
if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } }
renderComments();
fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } })
.then(r => r.ok ? r.json() : null)
.then(rows => {
if (Array.isArray(rows)) {
allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' }));
renderComments();
}
})
.catch(() => {});
}
</script>
<script src="wp-format.js"></script>
<script src="wp-chrome.js"></script>
</body>
</html>