Files
Project-SDE-WP-Suite/html/index.html
n.siegfried 4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

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

704 lines
27 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>
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.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; }
/* Shown once when the project someone had open turns out to have been archived
rather than deleted — otherwise the picker just silently resets on them. */
.proj-archived-note { background: #fdf6dd; border: 1px solid #f1c21b; color: #8e6a00;
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
.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>
<!-- USER DIRECTORY -->
<a href="users.html" class="card" id="card-users">
<h3>User Directory</h3>
<p>Who is on this project — names, job functions and how to reach them. Administrators and Project Super Users also create accounts, set permissions and grant project access from here.</p>
<button class="card-button" id="card-users-btn">Open Directory</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();
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
if(dropped) ProjectData.setActive(null);
renderProjectPicker();
applyActiveProject();
// "No longer in the list" used to mean one thing — deleted. Now it also
// means archived, and resetting someone to "Select a project" with no word
// about it sends them hunting for a job that is merely finished.
if(dropped) explainDroppedProject(dropped);
});
}
function explainDroppedProject(p){
// The project is still readable when it's archived; a delete (or access being
// taken away) fails here, and that case genuinely has nothing to say.
ProjectData.get(p.id).then(full => {
if(!full || !full.archived) return;
const box = document.getElementById('project-picker');
if(!box || document.getElementById('proj-archived-note')) return;
const note = document.createElement('div');
note.id = 'proj-archived-note';
note.className = 'proj-archived-note';
note.innerHTML = `<strong>${esc(p.name || 'The project you had open')}</strong> has been
archived — it is read-only and no longer listed here. An administrator can unarchive it
from the Admin Console.`;
box.insertBefore(note, box.firstChild);
}).catch(() => {});
}
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-chrome.js"></script>
</body>
</html>