add core feedback system

This commit is contained in:
Franz Rolfsvaag 2026-06-18 04:24:50 +02:00
parent 606c9452e7
commit 9c27d12445
12 changed files with 2183 additions and 3 deletions

302
TODO.md
View File

@ -122,8 +122,310 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values.
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
- Review localization/translation keys if present so simplified wording remains consistent across languages.
## Core Feedback System
First implementation pass completed 2026-06-18. Remaining feedback work is mainly optional screenshot capture/storage, richer duplicate detection, date/plugin/area admin filters, issue creation, OKF correction conversion, and deeper attachment/DOM snapshot handling. Keep the detailed checklist below as the remaining reference for hardening and follow-up passes.
- Implement a general core feedback system available to logged-in users only.
- Add a persistent feedback entry point somewhere logical in the UI.
- Add `/feedback` under the Community navbar section for logged-in users.
- Add `/admin/feedback` as the admin review entry point.
- Ensure the feedback system is core-level functionality and can cover any area, scope, feature, page, plugin, or UI element.
- Keep the AI Improvement Center separate from this system; it should continue handling AI reply quality feedback.
- Add a `wrong tool` classification to the AI Improvement Center for cases where the AI used the wrong tool, action, integration, or capability.
### Feedback Categories and Scope
- Use generic feedback categories based on issue type rather than feature-specific categories.
- Suggested categories:
- bug
- confusing wording
- broken interaction
- visual/layout issue
- accessibility issue
- missing feature
- improvement suggestion
- performance issue
- permission/access issue
- unexpected behavior
- other
- Allow feedback to target:
- the whole page
- a specific clicked UI element
- the current feature/page
- a plugin
- a broad system area
- other/custom scope
- Encourage users to submit one feedback item per issue.
- Add helper text explaining that small, specific feedback reports are preferred over broad combined reports.
### Feedback Submission UI
- Add a feedback modal for creating feedback.
- The modal should be accessible from the persistent feedback button and from the custom context menu.
- Require logged-in user identity for all feedback submissions.
- Allow the user to enter:
- short summary/title
- category
- severity/priority
- scope/target
- detailed description
- optional steps to reproduce
- optional expected behavior
- optional actual behavior
- Support severity/priority values such as:
- minor
- confusing
- broken
- urgent
- security/sensitive
- suggestion
- Pre-fill feedback scope based on the current page or clicked element when available.
- Allow users to change the auto-detected scope before submitting.
- Show clear confirmation after feedback is submitted.
### Site-Wide Custom Context Menu
- Replace the standard browser right-click context menu across Lumi with a custom site-wide context menu.
- Include the following actions:
- Back
- Forward
- Copy
- Cut
- Paste
- Link to here
- Hard reload
- Feedback
- Ensure context menu actions respect browser permissions and limitations.
- Ensure copy, cut, and paste only appear or work where appropriate.
- Implement “Link to here” by creating a URL to the current page and, when possible, the clicked element or section.
- Implement “Hard reload” as a cache-bypassing reload comparable to a developer hard refresh where possible.
- Add the Feedback action to open the feedback modal with the clicked element as the target.
- Preserve keyboard accessibility and provide fallback behavior if custom context menu features are unavailable in a browser.
### Element-Targeted Feedback
- When feedback is opened from the context menu, capture the clicked element as the feedback target.
- Store safe metadata about the clicked element, such as:
- selector or generated stable path
- element tag
- visible text snippet
- aria-label/title/role when available
- nearest form label or heading
- page URL
- page title
- viewport size
- Do not store sensitive field values from passwords, tokens, secrets, or private inputs.
- Allow users to choose whether feedback is about the clicked element, the whole page, the current feature, a plugin, or another scope.
- Highlight the selected target while the feedback modal is open when practical.
- Remove the highlight when the modal closes.
### Optional Screenshot Support
- Add optional browser-generated screenshot support.
- Screenshots must be opt-in by the submitting user.
- Do not require screenshots for feedback submission.
- Support full-page screenshots where technically possible.
- Support cropped/section-only screenshots where technically possible.
- Temporarily hide feedback buttons, feedback modals, context menus, and feedback overlays while capturing screenshots.
- Restore hidden feedback UI immediately after capture.
- Store screenshots only when explicitly attached by the user.
- Clearly warn users not to include sensitive information in screenshots.
- Allow users to remove an attached screenshot before submitting.
### Optional Diagnostic Data
- Automatically attach basic non-sensitive context:
- user ID
- current URL
- page title
- timestamp
- selected scope
- target metadata if element-targeted
- Make browser/user agent and screenshot/DOM snapshot optional opt-in fields.
- If DOM snapshot support is added, sanitize it before storage.
- Do not capture sensitive form values, passwords, tokens, secrets, private messages, or hidden data.
- Clearly label optional diagnostic data so users understand what they are submitting.
### Feedback Visibility for Users
- On `/feedback`, show logged-in users a general list of current and past feedback that has not been deleted.
- Public/general feedback list should only show basic non-identifying information:
- summary
- category
- general scope
- status
- created date
- last updated date
- Do not expose submitter identity in the general feedback list.
- Allow users to see their own feedback in detail.
- Make it easy to distinguish the current users own feedback from general feedback.
- Consider a separate “My feedback” section or a clear badge/label in the shared list.
- Allow users to add comments to their own feedback when follow-up is needed.
- Allow users to see admin replies that are visible to the submitter.
### User Feedback Notifications
- Add per-user feedback notification badges.
- Show a green badge/sphere with the number of the users feedback items solved since they last opened the feedback center.
- Show a red badge/sphere with the number of the users feedback items marked as needing more context.
- Show a grey badge/sphere with the number of the users feedback items marked as declined, rejected, duplicate, wont fix, not planned, or otherwise not being worked on.
- Reset or update badge counts when the user opens the feedback center and views the relevant items.
- Ensure notification badges only reflect the logged-in users own feedback.
### Feedback Statuses
- Support feedback statuses such as:
- new
- reviewed
- accepted
- planned
- in progress
- fixed
- solved
- needs more context
- duplicate
- rejected
- not planned
- wont fix
- archived
- deleted
- Allow admins to change status from the admin review UI.
- Store status history with timestamps and actor information.
- Show user-friendly status names and explanations on `/feedback`.
### Replies, Comments, and Work Notes
- Allow admins to reply to feedback.
- Allow submitters to comment on their own feedback.
- Allow admins to add private internal work notes that are not visible to normal users.
- Allow admins to mark feedback as “needs more context”.
- When feedback is marked “needs more context”, notify the submitter through the red feedback badge.
- Allow the submitter to add additional context/comments after the request.
- Distinguish clearly between public/admin replies, submitter comments, and private admin work notes.
### Admin Feedback Review
- Add `/admin/feedback` for admin feedback review.
- Admins should be able to view full feedback details, including:
- submitter
- summary
- description
- category
- severity
- scope
- target metadata
- current URL
- browser/user agent if submitted
- screenshot if submitted
- DOM snapshot if submitted
- comments/replies
- work notes
- status history
- Add filters for:
- status
- category
- severity
- scope
- plugin/area
- submitter
- date
- needs admin action
- Add sorting by newest, oldest, severity, status, and last activity.
- Add admin actions:
- change status
- assign category
- change severity
- reply to submitter
- add internal work note
- request more context
- mark duplicate
- archive
- delete
- convert to TODO
- convert to issue
- convert to OKF correction where relevant
- link to existing TODO/issue/correction
### TODO and Issue Conversion
- Allow admins to convert feedback into a TODO entry.
- Support injecting generated TODO entries into `TODO.md`.
- Preserve the existing TODO format when injecting new entries.
- Allow admins to edit the generated TODO text before saving.
- Add enough context to generated TODO items to be useful for later Codex work.
- Allow admins to convert feedback into a GitHub/Gitea issue if configured.
- Allow admins to link feedback to manually created TODOs or issues.
- Store links between feedback items and generated TODOs/issues.
- Do not automatically create TODOs or issues without admin confirmation.
### OKF and AI Improvement Integration
- Keep this feedback system separate from AI reply feedback.
- Do not use this feedback system to rate individual AI replies.
- Use the AI Improvement Center for AI answer quality, wrong answers, bad context, and correction review.
- Add `wrong tool` as a classification in the AI Improvement Center.
- Allow core feedback to cover AI features, AI UI, AI configuration, and AI workflow problems.
- Allow admins to convert relevant core feedback into OKF corrections only when the feedback concerns documentation, feature behavior, terminology, or community/system knowledge.
- Do not automatically modify OKF based on general feedback.
### Rate Limits and Abuse Prevention
- Add moderate rate limits for feedback submissions.
- Rate limits should prevent spam without blocking normal use.
- Encourage one feedback item per issue.
- Add validation to prevent empty or extremely vague feedback.
- Add optional duplicate detection based on similar title, scope, and page.
- If similar existing feedback exists, suggest adding a comment or upvote/support instead of creating a duplicate.
- Allow admins to delete or archive abusive/spam feedback.
### Data Storage and Preservation
- Store feedback data in SQLite or the existing app storage system.
- Store screenshots and optional attachments in a protected feedback uploads/storage directory.
- Preserve feedback data across repo updates, ZIP updates, migrations, and recovery operations.
- Include feedback data in the protected user-data list for backups and update preservation.
- Never overwrite feedback data during core/plugin updates.
- Include feedback data in backup/restore planning.
- Store enough metadata for auditability:
- created_at
- updated_at
- submitter_id
- assigned_admin_id if used
- status history
- comments/replies
- linked TODOs/issues/corrections
### Privacy and Safety
- Do not expose submitter identity in the general feedback list.
- Only admins should see submitter identity and full diagnostic details.
- Users should only see full detail for their own feedback.
- Sanitize all submitted HTML/text before rendering.
- Avoid capturing sensitive form values.
- Make optional diagnostic capture transparent to users.
- Add admin controls for deleting sensitive feedback, screenshots, or diagnostic data.
- Ensure permissions are enforced server-side, not only in the UI.
### Acceptance Criteria
- Logged-in users can submit general feedback from a persistent UI entry point.
- Logged-in users can submit targeted feedback from the custom right-click context menu.
- Right-click context menu includes Back, Forward, Copy, Cut, Paste, Link to here, Hard reload, and Feedback.
- Feedback modal can target the whole page, clicked element, current feature/page, plugin, broad system area, or custom scope.
- Users can optionally attach browser-generated screenshots while feedback UI is hidden during capture.
- `/feedback` appears under the Community navbar section for logged-in users.
- `/feedback` shows general non-identifying feedback summaries and detailed feedback for the current users own submissions.
- Users see per-user notification badges for solved, needs-context, and not-worked-on feedback.
- Admins can review and manage feedback at `/admin/feedback`.
- Admins can reply, add work notes, request more context, change statuses, and convert feedback to TODOs/issues/corrections.
- Feedback data is preserved across updates and included in protected user data.
- AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification.
## Done
- 2026-06-18: Added a core feedback system first pass on `experimental-feedback-system`: SQLite feedback entries/comments/status history/view state, logged-in `/feedback`, admin `/admin/feedback`, persistent feedback modal, site-wide custom context menu with element-targeted feedback, per-user notification badges, admin replies/work notes/status changes/TODO conversion, and AI Improvement Center `wrong_tool_usage` confirmed present.
- 2026-06-17: Added Economy cleanup for fallback-name artifact users such as `SammyCat-2`, merging them into matching real platform users and making Economy stats tolerate missing/mid-migration tables; bumped Economy Framework to v0.2.9.
- 2026-06-17: Fixed update metadata for renamed Economy plugins so legacy installed rows are folded into canonical `economy-*` plugin update rows instead of appearing as separate installs; bumped core to v0.1.8.
- 2026-06-17: Renamed all remaining Economy internals from the old misspelled IDs/paths/tables to `economy-*`, added startup migration for legacy plugin rows, settings, command usage IDs, tables, uploads, asset paths, old URLs, and bumped core/plugin patch versions.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.1.8",
"version": "0.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.1.8",
"version": "0.1.9",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -0,0 +1,132 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const ejs = require("ejs");
const root = path.join(__dirname, "..");
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-feedback-test-"));
const serviceDir = path.join(sandbox, "src", "services");
fs.mkdirSync(serviceDir, { recursive: true });
for (const file of ["db.js", "feedback.js"]) {
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
}
let database = null;
try {
database = require(path.join(serviceDir, "db.js"));
database.migrate();
const feedback = require(path.join(serviceDir, "feedback.js"));
const db = database.db;
const now = Date.now();
db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run("user-1", "FeedbackUser", now, now);
db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run("admin-1", "AdminUser", now, now);
const entry = feedback.createFeedback({
summary: "Save button fails",
category: "broken_interaction",
severity: "broken",
scope_type: "element",
scope_label: "Save settings button",
description: "Clicking the save button does not apply the changes.",
steps_to_reproduce: "Open settings, change a field, click save.",
expected_behavior: "The settings should be saved.",
actual_behavior: "The page reloads without saving.",
current_url: "http://localhost/admin/settings",
page_title: "Settings",
target_metadata: {
selector: "#save-settings",
tag: "button",
text: "Save settings",
secret: "must not persist"
},
diagnostics: {
user_agent: "verification-agent",
viewport: "1200x800",
hidden: "must not persist"
}
}, { id: "user-1" });
assert.equal(entry.status, "new");
assert.equal(entry.target_metadata.secret, undefined);
assert.equal(entry.diagnostics.hidden, undefined);
assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true);
assert.equal(feedback.listMyFeedback("user-1").length, 1);
feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" });
const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1");
assert.equal(withComment.comments.length, 1);
assert.equal(withComment.comments[0].kind, "submitter_comment");
feedback.markFeedbackViewed("user-1");
feedback.adminUpdateFeedback(entry.id, {
status: "needs_more_context",
category: "bug",
severity: "urgent",
admin_reply: "Can you add which browser this happened in?",
work_note: "Likely settings form regression.",
status_note: "Need browser detail."
}, { id: "admin-1" });
const notices = feedback.notificationSummary("user-1");
assert.equal(notices.needs_context, 1);
const adminEntry = feedback.getFeedbackForAdmin(entry.id);
assert(adminEntry.comments.some((comment) => comment.kind === "admin_reply"));
assert(adminEntry.comments.some((comment) => comment.kind === "work_note"));
const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1");
assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false);
const todoPath = path.join(sandbox, "TODO.md");
fs.writeFileSync(todoPath, "# TODO\n\n## Done\n");
feedback.convertFeedbackToTodo(entry.id, "Fix feedback verification issue.", { id: "admin-1" }, todoPath);
assert(fs.readFileSync(todoPath, "utf8").includes("## Feedback-Derived TODOs"));
assert(fs.readFileSync(todoPath, "utf8").includes("Fix feedback verification issue."));
const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs");
const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs");
const commonLocals = {
siteTitle: "Lumi Bot",
assetVersion: "verify",
theme: null,
botAvatar: null,
navSections: [],
user: { id: "user-1", username: "FeedbackUser" },
userAvatar: null,
userInitial: "F",
platformLogins: [],
platformLinks: [],
platforms: [],
flash: null,
softError: null,
feedbackNotifications: feedback.notificationSummary("user-1"),
feedbackOptions: feedback.feedbackOptions()
};
const userRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), {
...commonLocals,
title: "Feedback",
feedbackList: feedback.listPublicFeedback({ userId: "user-1" }),
myFeedback: feedback.listMyFeedback("user-1"),
selectedFeedback: feedback.getFeedbackForSubmitter(entry.id, "user-1")
}, { filename: feedbackView });
assert(userRendered.includes("My feedback"));
assert(userRendered.includes("Admin reply"));
const adminRendered = ejs.render(fs.readFileSync(adminView, "utf8"), {
...commonLocals,
user: { id: "admin-1", username: "AdminUser", isAdmin: true },
userInitial: "A",
title: "Feedback review",
feedbackItems: feedback.listFeedbackForAdmin({}),
filters: { status: "", category: "", severity: "", scope: "", submitter: "", needs_action: "", sort: "last_activity" }
}, { filename: adminView });
assert(adminRendered.includes("Feedback queue"));
assert(adminRendered.includes("Convert to TODO"));
console.log("Core feedback system verification passed.");
} finally {
database?.db.close();
fs.rmSync(sandbox, { recursive: true, force: true });
}

View File

@ -149,6 +149,66 @@ function migrate() {
);
CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at);
CREATE TABLE IF NOT EXISTS feedback_entries (
id TEXT PRIMARY KEY,
submitter_id TEXT NOT NULL,
summary TEXT NOT NULL,
category TEXT NOT NULL,
severity TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_label TEXT,
target_metadata_json TEXT NOT NULL DEFAULT '{}',
current_url TEXT,
page_title TEXT,
description TEXT NOT NULL,
steps_to_reproduce TEXT,
expected_behavior TEXT,
actual_behavior TEXT,
diagnostics_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'new',
admin_reply TEXT,
assigned_admin_id TEXT,
linked_todo TEXT,
linked_issue TEXT,
linked_correction TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_activity_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE INDEX IF NOT EXISTS feedback_entries_submitter_idx ON feedback_entries (submitter_id);
CREATE INDEX IF NOT EXISTS feedback_entries_status_idx ON feedback_entries (status);
CREATE INDEX IF NOT EXISTS feedback_entries_last_activity_idx ON feedback_entries (last_activity_at);
CREATE TABLE IF NOT EXISTS feedback_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feedback_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
kind TEXT NOT NULL,
body TEXT NOT NULL,
visible_to_submitter INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_comments_feedback_idx ON feedback_comments (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_status_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feedback_id TEXT NOT NULL,
status TEXT NOT NULL,
actor_id TEXT,
note TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_status_history_feedback_idx ON feedback_status_history (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_view_state (
user_id TEXT PRIMARY KEY,
last_seen_at INTEGER NOT NULL
);
`);
const columns = db

605
src/services/feedback.js Normal file
View File

@ -0,0 +1,605 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { db } = require("./db");
const FEEDBACK_CATEGORIES = Object.freeze([
"bug",
"confusing_wording",
"broken_interaction",
"visual_layout_issue",
"accessibility_issue",
"missing_feature",
"improvement_suggestion",
"performance_issue",
"permission_access_issue",
"unexpected_behavior",
"other"
]);
const FEEDBACK_SEVERITIES = Object.freeze([
"minor",
"confusing",
"broken",
"urgent",
"security_sensitive",
"suggestion"
]);
const FEEDBACK_SCOPE_TYPES = Object.freeze([
"page",
"element",
"feature",
"plugin",
"system",
"other"
]);
const FEEDBACK_STATUSES = Object.freeze([
"new",
"reviewed",
"accepted",
"planned",
"in_progress",
"fixed",
"solved",
"needs_more_context",
"duplicate",
"rejected",
"not_planned",
"wont_fix",
"archived",
"deleted"
]);
const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted"));
const SOLVED_STATUSES = new Set(["fixed", "solved"]);
const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]);
const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]);
const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 };
const CATEGORY_LABELS = Object.freeze({
bug: "Bug",
confusing_wording: "Confusing wording",
broken_interaction: "Broken interaction",
visual_layout_issue: "Visual/layout issue",
accessibility_issue: "Accessibility issue",
missing_feature: "Missing feature",
improvement_suggestion: "Improvement suggestion",
performance_issue: "Performance issue",
permission_access_issue: "Permission/access issue",
unexpected_behavior: "Unexpected behavior",
other: "Other"
});
const SEVERITY_LABELS = Object.freeze({
minor: "Minor",
confusing: "Confusing",
broken: "Broken",
urgent: "Urgent",
security_sensitive: "Security/sensitive",
suggestion: "Suggestion"
});
const SCOPE_LABELS = Object.freeze({
page: "Whole page",
element: "Clicked element",
feature: "Current feature/page",
plugin: "Plugin",
system: "System area",
other: "Other"
});
const STATUS_LABELS = Object.freeze({
new: "New",
reviewed: "Reviewed",
accepted: "Accepted",
planned: "Planned",
in_progress: "In progress",
fixed: "Fixed",
solved: "Solved",
needs_more_context: "Needs more context",
duplicate: "Duplicate",
rejected: "Rejected",
not_planned: "Not planned",
wont_fix: "Won't fix",
archived: "Archived",
deleted: "Deleted"
});
const STATUS_HELP = Object.freeze({
new: "Submitted and waiting for review.",
reviewed: "Seen by an administrator.",
accepted: "Accepted as valid feedback.",
planned: "Planned for a future pass.",
in_progress: "Being worked on.",
fixed: "A fix has been made.",
solved: "Resolved and no longer needs action.",
needs_more_context: "The submitter needs to add more detail.",
duplicate: "Covered by another feedback item.",
rejected: "Rejected after review.",
not_planned: "Not planned for implementation.",
wont_fix: "Reviewed, but will not be changed.",
archived: "Archived for record keeping.",
deleted: "Deleted by an administrator."
});
function createFeedback(input, actor) {
if (!actor?.id) {
throw new Error("Feedback requires a logged-in user.");
}
enforceRateLimit(actor.id);
const now = Date.now();
const entry = normalizeFeedbackInput(input);
const id = crypto.randomUUID();
db.transaction(() => {
db.prepare(
"INSERT INTO feedback_entries " +
"(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, status, created_at, updated_at, last_activity_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)"
).run(
id,
actor.id,
entry.summary,
entry.category,
entry.severity,
entry.scope_type,
entry.scope_label,
JSON.stringify(entry.target_metadata),
entry.current_url,
entry.page_title,
entry.description,
entry.steps_to_reproduce,
entry.expected_behavior,
entry.actual_behavior,
JSON.stringify(entry.diagnostics),
now,
now,
now
);
addStatusHistory(id, "new", actor.id, "Submitted", now);
})();
return getFeedbackForAdmin(id);
}
function listPublicFeedback({ userId, limit = 100 } = {}) {
const rows = db
.prepare(
"SELECT id, submitter_id, summary, category, severity, scope_type, scope_label, status, created_at, updated_at, last_activity_at " +
"FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " +
"ORDER BY last_activity_at DESC LIMIT ?"
)
.all(limit)
.map((row) => ({
...decorateLabels(row),
is_mine: userId ? row.submitter_id === userId : false,
submitter_id: undefined
}));
return rows;
}
function listMyFeedback(userId) {
if (!userId) return [];
return db
.prepare(
"SELECT * FROM feedback_entries WHERE submitter_id = ? AND deleted_at IS NULL AND status != 'deleted' ORDER BY last_activity_at DESC"
)
.all(userId)
.map((row) => hydrateFeedback(row, { admin: false }));
}
function getFeedbackForSubmitter(id, userId) {
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
if (!row || row.deleted_at || row.status === "deleted" || row.submitter_id !== userId) {
return null;
}
return hydrateFeedback(row, { admin: false });
}
function getFeedbackForAdmin(id) {
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
return row ? hydrateFeedback(row, { admin: true }) : null;
}
function listFeedbackForAdmin(filters = {}) {
const where = [];
const params = [];
if (filters.status && FEEDBACK_STATUSES.includes(filters.status)) {
where.push("feedback_entries.status = ?");
params.push(filters.status);
} else {
where.push("feedback_entries.status != 'deleted'");
where.push("feedback_entries.deleted_at IS NULL");
}
if (filters.category && FEEDBACK_CATEGORIES.includes(filters.category)) {
where.push("feedback_entries.category = ?");
params.push(filters.category);
}
if (filters.severity && FEEDBACK_SEVERITIES.includes(filters.severity)) {
where.push("feedback_entries.severity = ?");
params.push(filters.severity);
}
if (filters.scope && FEEDBACK_SCOPE_TYPES.includes(filters.scope)) {
where.push("feedback_entries.scope_type = ?");
params.push(filters.scope);
}
if (filters.submitter) {
where.push(
"(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))"
);
params.push(filters.submitter, `%${filters.submitter}%`);
}
if (filters.needs_action === "1") {
where.push("feedback_entries.status IN ('new', 'needs_more_context')");
}
const order = {
oldest: "feedback_entries.created_at ASC",
severity: severityOrderSql(),
status: "feedback_entries.status ASC, feedback_entries.last_activity_at DESC",
last_activity: "feedback_entries.last_activity_at DESC",
newest: "feedback_entries.created_at DESC"
}[filters.sort || "last_activity"];
return db
.prepare(
"SELECT feedback_entries.*, user_profiles.internal_username AS submitter_name " +
"FROM feedback_entries LEFT JOIN user_profiles ON user_profiles.id = feedback_entries.submitter_id " +
`WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250`
)
.all(...params)
.map((row) => hydrateFeedback(row, { admin: true }));
}
function addSubmitterComment(id, body, actor) {
const entry = getFeedbackForSubmitter(id, actor?.id);
if (!entry) {
throw new Error("Feedback item was not found.");
}
const comment = cleanText(body, 4000);
if (comment.length < 3) {
throw new Error("Comment is too short.");
}
const now = Date.now();
db.transaction(() => {
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'submitter_comment', ?, 1, ?)"
).run(id, actor.id, comment, now);
touchFeedback(id, now);
})();
}
function adminUpdateFeedback(id, input, actor) {
const current = getFeedbackForAdmin(id);
if (!current) {
throw new Error("Feedback item was not found.");
}
const now = Date.now();
const nextCategory = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : current.category;
const nextSeverity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : current.severity;
const nextStatus = FEEDBACK_STATUSES.includes(input.status) ? input.status : current.status;
const adminReply = cleanText(input.admin_reply, 6000);
const workNote = cleanText(input.work_note, 6000);
const statusNote = cleanText(input.status_note, 1000);
const linkedIssue = cleanText(input.linked_issue, 1000);
const linkedCorrection = cleanText(input.linked_correction, 1000);
db.transaction(() => {
db.prepare(
"UPDATE feedback_entries SET category = ?, severity = ?, status = ?, admin_reply = ?, linked_issue = ?, linked_correction = ?, updated_at = ?, last_activity_at = ?, deleted_at = ? WHERE id = ?"
).run(
nextCategory,
nextSeverity,
nextStatus,
adminReply || null,
linkedIssue || null,
linkedCorrection || null,
now,
now,
nextStatus === "deleted" ? now : null,
id
);
if (nextStatus !== current.status) {
addStatusHistory(id, nextStatus, actor.id, statusNote, now);
}
if (adminReply && adminReply !== (current.admin_reply || "")) {
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'admin_reply', ?, 1, ?)"
).run(id, actor.id, adminReply, now);
}
if (workNote) {
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
).run(id, actor.id, workNote, now);
}
})();
return getFeedbackForAdmin(id);
}
function convertFeedbackToTodo(id, body, actor, todoPath = path.join(__dirname, "..", "..", "TODO.md")) {
const entry = getFeedbackForAdmin(id);
if (!entry) {
throw new Error("Feedback item was not found.");
}
const text = cleanText(body, 4000) || defaultTodoText(entry);
if (!text) {
throw new Error("TODO text is required.");
}
const current = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, "utf8") : "# TODO\n\n## Done\n";
const section = "## Feedback-Derived TODOs";
const bullet = text
.split(/\r?\n/)
.map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`))
.join("\n");
let next = current;
if (!current.includes(section)) {
next = current.replace(/\n## Done\b/, `\n${section}\n\n${bullet}\n\n## Done`);
if (next === current) {
next = `${current.replace(/\s*$/, "")}\n\n${section}\n\n${bullet}\n`;
}
} else {
next = current.replace(section, `${section}\n\n${bullet}`);
}
fs.writeFileSync(todoPath, next);
const now = Date.now();
db.transaction(() => {
db.prepare(
"UPDATE feedback_entries SET linked_todo = ?, updated_at = ?, last_activity_at = ? WHERE id = ?"
).run(text, now, now, id);
db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
).run(id, actor.id, `Converted to TODO: ${text}`, now);
})();
return text;
}
function markFeedbackViewed(userId) {
if (!userId) return;
db.prepare(
"INSERT INTO feedback_view_state (user_id, last_seen_at) VALUES (?, ?) " +
"ON CONFLICT(user_id) DO UPDATE SET last_seen_at = excluded.last_seen_at"
).run(userId, Date.now());
}
function notificationSummary(userId) {
if (!userId) {
return { solved: 0, needs_context: 0, not_worked: 0, total: 0 };
}
const viewed = db
.prepare("SELECT last_seen_at FROM feedback_view_state WHERE user_id = ?")
.get(userId);
const since = viewed?.last_seen_at || 0;
const rows = db
.prepare(
"SELECT status, updated_at FROM feedback_entries WHERE submitter_id = ? AND updated_at > ? AND deleted_at IS NULL AND status != 'deleted'"
)
.all(userId, since);
const summary = { solved: 0, needs_context: 0, not_worked: 0, total: 0 };
for (const row of rows) {
if (SOLVED_STATUSES.has(row.status)) summary.solved += 1;
if (NEEDS_CONTEXT_STATUSES.has(row.status)) summary.needs_context += 1;
if (NOT_WORKING_STATUSES.has(row.status)) summary.not_worked += 1;
}
summary.total = summary.solved + summary.needs_context + summary.not_worked;
return summary;
}
function feedbackOptions() {
return {
categories: FEEDBACK_CATEGORIES.map((value) => ({ value, label: CATEGORY_LABELS[value] })),
severities: FEEDBACK_SEVERITIES.map((value) => ({ value, label: SEVERITY_LABELS[value] })),
scopes: FEEDBACK_SCOPE_TYPES.map((value) => ({ value, label: SCOPE_LABELS[value] })),
statuses: FEEDBACK_STATUSES.map((value) => ({
value,
label: STATUS_LABELS[value],
help: STATUS_HELP[value]
}))
};
}
function normalizeFeedbackInput(input = {}) {
const summary = cleanText(input.summary, 140);
const description = cleanText(input.description, 6000);
if (summary.length < 6) {
throw new Error("Feedback needs a short summary with at least 6 characters.");
}
if (description.length < 10) {
throw new Error("Feedback needs a description with at least 10 characters.");
}
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other";
const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor";
const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page";
return {
summary,
category,
severity,
scope_type: scopeType,
scope_label: cleanText(input.scope_label, 240),
target_metadata: sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata),
current_url: cleanUrl(input.current_url),
page_title: cleanText(input.page_title, 240),
description,
steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000),
expected_behavior: cleanText(input.expected_behavior, 4000),
actual_behavior: cleanText(input.actual_behavior, 4000),
diagnostics: sanitizeJsonObject(input.diagnostics, sanitizeDiagnostics)
};
}
function enforceRateLimit(userId) {
const cutoff = Date.now() - RATE_LIMIT.windowMs;
const count = db
.prepare(
"SELECT COUNT(*) AS count FROM feedback_entries WHERE submitter_id = ? AND created_at >= ?"
)
.get(userId, cutoff).count;
if (count >= RATE_LIMIT.max) {
throw new Error("Too many feedback reports were submitted recently. Please wait a few minutes.");
}
}
function hydrateFeedback(row, { admin }) {
const parsed = {
...decorateLabels(row),
target_metadata: parseJson(row.target_metadata_json, {}),
diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {},
comments: commentsFor(row.id, admin),
history: statusHistoryFor(row.id)
};
if (!admin) {
delete parsed.submitter_id;
delete parsed.assigned_admin_id;
delete parsed.diagnostics_json;
parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter);
}
return parsed;
}
function commentsFor(feedbackId, admin) {
return db
.prepare(
"SELECT feedback_comments.*, user_profiles.internal_username AS actor_name " +
"FROM feedback_comments LEFT JOIN user_profiles ON user_profiles.id = feedback_comments.actor_id " +
"WHERE feedback_id = ? ORDER BY created_at ASC"
)
.all(feedbackId)
.filter((row) => admin || row.visible_to_submitter)
.map((row) => ({
...row,
visible_to_submitter: Boolean(row.visible_to_submitter),
kind_label: commentKindLabel(row.kind)
}));
}
function statusHistoryFor(feedbackId) {
return db
.prepare(
"SELECT feedback_status_history.*, user_profiles.internal_username AS actor_name " +
"FROM feedback_status_history LEFT JOIN user_profiles ON user_profiles.id = feedback_status_history.actor_id " +
"WHERE feedback_id = ? ORDER BY created_at ASC"
)
.all(feedbackId)
.map((row) => decorateLabels(row));
}
function addStatusHistory(feedbackId, status, actorId, note, now) {
db.prepare(
"INSERT INTO feedback_status_history (feedback_id, status, actor_id, note, created_at) VALUES (?, ?, ?, ?, ?)"
).run(feedbackId, status, actorId || null, cleanText(note, 1000), now);
}
function touchFeedback(id, now = Date.now()) {
db.prepare("UPDATE feedback_entries SET updated_at = ?, last_activity_at = ? WHERE id = ?").run(now, now, id);
}
function decorateLabels(row) {
return {
...row,
category_label: CATEGORY_LABELS[row.category] || row.category,
severity_label: SEVERITY_LABELS[row.severity] || row.severity,
scope_label_display: row.scope_label || SCOPE_LABELS[row.scope_type] || row.scope_type,
scope_type_label: SCOPE_LABELS[row.scope_type] || row.scope_type,
status_label: STATUS_LABELS[row.status] || row.status,
status_help: STATUS_HELP[row.status] || ""
};
}
function defaultTodoText(entry) {
const url = entry.current_url ? ` (${entry.current_url})` : "";
return `[Feedback ${entry.id.slice(0, 8)}] ${entry.summary}${url}: ${entry.description.slice(0, 300)}`;
}
function sanitizeTargetMetadata(value) {
return {
selector: cleanText(value.selector, 500),
tag: cleanText(value.tag, 40),
text: cleanText(value.text, 300),
aria_label: cleanText(value.aria_label, 200),
title: cleanText(value.title, 200),
role: cleanText(value.role, 80),
label: cleanText(value.label, 200),
heading: cleanText(value.heading, 200),
page_url: cleanUrl(value.page_url),
page_title: cleanText(value.page_title, 240),
viewport: cleanText(value.viewport, 80)
};
}
function sanitizeDiagnostics(value) {
return {
user_agent: cleanText(value.user_agent, 500),
viewport: cleanText(value.viewport, 80),
language: cleanText(value.language, 80)
};
}
function sanitizeJsonObject(value, sanitizer) {
const object = typeof value === "object" && value && !Array.isArray(value) ? value : {};
return removeEmptyFields(sanitizer(object));
}
function removeEmptyFields(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== "" && entry !== null && entry !== undefined));
}
function parseJson(value, fallback) {
try {
const parsed = JSON.parse(value || "");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
function cleanText(value, max) {
return String(value || "").replace(/\s+\n/g, "\n").trim().slice(0, max);
}
function cleanUrl(value) {
const raw = cleanText(value, 1000);
if (!raw) return "";
try {
const url = new URL(raw, "http://localhost");
if (!["http:", "https:"].includes(url.protocol)) return "";
return raw;
} catch {
return "";
}
}
function commentKindLabel(kind) {
return {
submitter_comment: "Submitter comment",
admin_reply: "Admin reply",
work_note: "Private work note"
}[kind] || kind;
}
function severityOrderSql() {
return "CASE feedback_entries.severity " +
"WHEN 'security_sensitive' THEN 0 " +
"WHEN 'urgent' THEN 1 " +
"WHEN 'broken' THEN 2 " +
"WHEN 'performance_issue' THEN 3 " +
"WHEN 'confusing' THEN 4 " +
"WHEN 'minor' THEN 5 " +
"ELSE 6 END, feedback_entries.last_activity_at DESC";
}
module.exports = {
FEEDBACK_CATEGORIES,
FEEDBACK_SCOPE_TYPES,
FEEDBACK_SEVERITIES,
FEEDBACK_STATUSES,
USER_VISIBLE_STATUSES,
adminUpdateFeedback,
convertFeedbackToTodo,
createFeedback,
feedbackOptions,
getFeedbackForAdmin,
getFeedbackForSubmitter,
listFeedbackForAdmin,
listMyFeedback,
listPublicFeedback,
markFeedbackViewed,
notificationSummary,
addSubmitterComment
};

View File

@ -945,4 +945,280 @@
markCopyResult(false);
}
});
const feedbackModal = document.querySelector("[data-feedback-modal]");
const feedbackForm = document.querySelector("[data-feedback-form]");
const contextMenu = document.querySelector("[data-lumi-context-menu]");
let contextTarget = null;
let highlightedFeedbackTarget = null;
const parseJson = (value, fallback = {}) => {
try {
return JSON.parse(value || "");
} catch {
return fallback;
}
};
const feedbackOptions = parseJson(feedbackModal?.dataset.feedbackOptions, {});
const setSelectOptions = (select, entries, preferred) => {
if (!select || !Array.isArray(entries)) return;
select.replaceChildren();
entries.forEach((entry) => {
const option = document.createElement("option");
option.value = entry.value;
option.textContent = entry.label;
if (entry.value === preferred) option.selected = true;
select.append(option);
});
};
if (feedbackForm) {
setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug");
setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor");
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page");
}
const sensitiveSelector = [
"input[type='password']",
"input[type='hidden']",
"input[name*='token' i]",
"input[name*='secret' i]",
"input[name*='password' i]",
"textarea[name*='token' i]",
"textarea[name*='secret' i]",
"textarea[name*='password' i]"
].join(",");
const safeText = (value, max = 300) => String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
const cssPath = (element) => {
if (!element || element === document.body || element === document.documentElement) return "body";
if (element.id) return `#${selectorEscape(element.id)}`;
const parts = [];
let node = element;
while (node && node.nodeType === 1 && node !== document.body && parts.length < 5) {
let part = node.tagName.toLowerCase();
if (node.classList.length) {
part += `.${Array.from(node.classList).slice(0, 2).map(selectorEscape).join(".")}`;
}
const parent = node.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.tagName === node.tagName);
if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
}
parts.unshift(part);
node = parent;
}
return parts.join(" > ") || "body";
};
const nearestText = (element, selector) => {
const match = element?.closest?.(selector);
return safeText(match?.innerText || match?.textContent || "", 200);
};
const safeElementText = (element) => {
if (!element || element.matches?.(sensitiveSelector)) return "";
if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) {
return "";
}
return safeText(element.innerText || element.textContent || "", 300);
};
const feedbackTargetMetadata = (element) => {
const target = element?.closest?.("button, a, input, textarea, select, label, summary, [role], .card, section, main") || element || document.body;
return {
selector: cssPath(target),
tag: target?.tagName?.toLowerCase?.() || "",
text: safeElementText(target),
aria_label: safeText(target?.getAttribute?.("aria-label"), 200),
title: safeText(target?.getAttribute?.("title"), 200),
role: safeText(target?.getAttribute?.("role"), 80),
label: nearestText(target, "label"),
heading: nearestText(target, "section, .card"),
page_url: window.location.href,
page_title: document.title,
viewport: `${window.innerWidth}x${window.innerHeight}`
};
};
const currentPageLabel = () => {
const h1 = document.querySelector("h1");
return safeText(h1?.innerText || document.title, 180);
};
const clearFeedbackHighlight = () => {
highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight");
highlightedFeedbackTarget = null;
};
const openFeedbackModal = ({ scope = "page", target = null } = {}) => {
if (!feedbackModal || !feedbackForm) return;
hideContextMenu();
clearFeedbackHighlight();
feedbackForm.reset();
setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug");
setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor");
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope);
const metadata = target ? feedbackTargetMetadata(target) : {};
const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]");
const urlInput = feedbackForm.querySelector("[data-feedback-current-url]");
const titleInput = feedbackForm.querySelector("[data-feedback-page-title]");
const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]");
const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]");
const status = feedbackForm.querySelector("[data-feedback-status]");
if (targetInput) targetInput.value = JSON.stringify(metadata);
if (urlInput) urlInput.value = window.location.href;
if (titleInput) titleInput.value = document.title;
if (diagnosticsInput) diagnosticsInput.value = "{}";
if (scopeLabel) scopeLabel.value = scope === "element"
? (metadata.label || metadata.text || metadata.selector || "Clicked element")
: currentPageLabel();
if (status) status.textContent = "";
if (target && scope === "element") {
highlightedFeedbackTarget = target;
highlightedFeedbackTarget.classList.add("feedback-target-highlight");
}
feedbackModal.classList.add("is-open");
feedbackModal.setAttribute("aria-hidden", "false");
feedbackForm.querySelector("input[name='summary']")?.focus();
};
const closeFeedbackModal = () => {
if (!feedbackModal) return;
feedbackModal.classList.remove("is-open");
feedbackModal.setAttribute("aria-hidden", "true");
clearFeedbackHighlight();
};
document.addEventListener("click", (event) => {
const openButton = event.target.closest("[data-feedback-open]");
if (openButton) {
event.preventDefault();
openFeedbackModal({ scope: openButton.dataset.feedbackScope || "page" });
return;
}
if (event.target.closest("[data-feedback-close]") || event.target === feedbackModal) {
event.preventDefault();
closeFeedbackModal();
}
});
feedbackForm?.addEventListener("submit", async (event) => {
event.preventDefault();
const submitter = feedbackForm.querySelector("button[type='submit']");
const status = feedbackForm.querySelector("[data-feedback-status]");
const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked;
const formData = new FormData(feedbackForm);
const payload = Object.fromEntries(formData.entries());
payload.target_metadata = parseJson(payload.target_metadata, {});
payload.diagnostics = includeBrowser
? {
user_agent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
language: navigator.language || ""
}
: {};
submitter.disabled = true;
if (status) status.textContent = "Submitting feedback...";
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Feedback could not be submitted.");
if (status) status.textContent = result.message || "Feedback submitted.";
window.setTimeout(() => {
closeFeedbackModal();
window.location.href = `/feedback?id=${encodeURIComponent(result.id)}`;
}, 700);
} catch (error) {
if (status) status.textContent = error.message;
submitter.disabled = false;
}
});
const isEditableTarget = (target) => {
const element = target?.closest?.("input, textarea, [contenteditable='true']");
return element && !element.matches("[readonly], [disabled]");
};
const updateContextMenuItems = (target) => {
if (!contextMenu) return;
const editable = Boolean(isEditableTarget(target));
contextMenu.querySelector("[data-context-action='cut']")?.toggleAttribute("hidden", !editable);
contextMenu.querySelector("[data-context-action='paste']")?.toggleAttribute("hidden", !editable);
contextMenu.querySelector("[data-context-action='copy']")?.toggleAttribute("hidden", false);
};
const showContextMenu = (event) => {
if (!contextMenu || event.shiftKey) return;
event.preventDefault();
contextTarget = event.target;
updateContextMenuItems(contextTarget);
contextMenu.hidden = false;
const width = contextMenu.offsetWidth || 180;
const height = contextMenu.offsetHeight || 260;
contextMenu.style.left = `${Math.min(event.clientX, window.innerWidth - width - 8)}px`;
contextMenu.style.top = `${Math.min(event.clientY, window.innerHeight - height - 8)}px`;
contextMenu.querySelector("button:not([hidden])")?.focus();
};
function hideContextMenu() {
if (!contextMenu) return;
contextMenu.hidden = true;
}
const linkForTarget = (target) => {
const element = target?.closest?.("[id]") || document.querySelector("h1[id]") || null;
const url = new URL(window.location.href);
if (element?.id) url.hash = element.id;
return url.toString();
};
const runContextAction = async (action) => {
const editable = isEditableTarget(contextTarget);
try {
if (action === "back") window.history.back();
if (action === "forward") window.history.forward();
if (action === "copy") {
const selection = window.getSelection?.().toString();
await copyText(selection || linkForTarget(contextTarget));
}
if (action === "cut" && editable) document.execCommand("cut");
if (action === "paste" && editable) {
const text = await navigator.clipboard?.readText?.();
if (text !== undefined) document.execCommand("insertText", false, text);
}
if (action === "link") await copyText(linkForTarget(contextTarget));
if (action === "reload") window.location.reload(true);
if (action === "feedback") openFeedbackModal({ scope: "element", target: contextTarget });
} catch {
if (action === "paste" && editable) document.execCommand("paste");
} finally {
if (action !== "feedback") hideContextMenu();
}
};
document.addEventListener("contextmenu", showContextMenu);
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-context-action]");
if (!button || !contextMenu?.contains(button)) {
hideContextMenu();
return;
}
event.preventDefault();
runContextAction(button.dataset.contextAction);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
hideContextMenu();
closeFeedbackModal();
}
});
window.addEventListener("blur", hideContextMenu);
})();

View File

@ -1255,6 +1255,274 @@ body {
color: var(--ink);
}
.feedback-floating-button {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 80;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--sea);
color: var(--ink-inverse, #081114);
font-weight: 800;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
cursor: pointer;
}
.feedback-floating-badge {
min-width: 22px;
min-height: 22px;
padding: 2px 7px;
border-radius: 999px;
background: var(--rose);
color: #fff;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.feedback-modal {
max-width: 820px;
}
.feedback-submit-status {
min-height: 20px;
color: var(--ink-soft);
font-weight: 700;
}
.feedback-page-actions,
.feedback-badge-row,
.feedback-two-col,
.feedback-admin-filters,
.feedback-detail-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.feedback-badge-row {
margin-top: 10px;
}
.feedback-status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-2);
color: var(--ink);
font-weight: 700;
}
.feedback-status-pill.solved {
border-color: rgba(48, 170, 112, 0.45);
}
.feedback-status-pill.needs-context {
border-color: rgba(236, 91, 91, 0.55);
}
.feedback-status-pill.not-worked {
border-color: rgba(148, 163, 184, 0.55);
}
.feedback-list,
.feedback-admin-list,
.feedback-comments {
display: grid;
gap: 10px;
}
.feedback-row,
.feedback-admin-item {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface-2);
}
.feedback-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 14px;
}
.feedback-row h3 {
margin: 0 0 4px;
}
.feedback-row.is-selected {
outline: 2px solid var(--sea);
}
.feedback-detail-grid {
align-items: stretch;
margin: 14px 0;
}
.feedback-detail-grid > div {
flex: 1 1 170px;
min-width: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
.feedback-detail-grid span {
display: block;
color: var(--ink-soft);
font-size: 13px;
}
.feedback-copy-block,
.feedback-comment {
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
.feedback-copy-block p,
.feedback-comment p {
white-space: pre-wrap;
margin-bottom: 0;
}
.feedback-two-col > * {
flex: 1 1 280px;
min-width: 0;
}
.feedback-comment-admin_reply {
border-left: 4px solid var(--sea);
}
.feedback-comment-submit_comment,
.feedback-comment-submitter_comment {
border-left: 4px solid var(--sun);
}
.feedback-comment-work_note {
border-left: 4px solid var(--ink-soft);
opacity: 0.92;
}
.feedback-status-fixed,
.feedback-status-solved {
border-color: rgba(48, 170, 112, 0.45);
color: var(--success);
}
.feedback-status-needs_more_context {
border-color: rgba(236, 91, 91, 0.55);
color: var(--rose);
}
.feedback-status-duplicate,
.feedback-status-rejected,
.feedback-status-not_planned,
.feedback-status-wont_fix {
border-color: rgba(148, 163, 184, 0.55);
color: var(--ink-soft);
}
.feedback-admin-item summary {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 14px;
cursor: pointer;
}
.feedback-admin-item summary small {
display: block;
margin-top: 4px;
color: var(--ink-soft);
}
.feedback-admin-body {
display: grid;
gap: 14px;
padding: 0 14px 14px;
}
.feedback-metadata {
padding: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
.feedback-metadata pre {
overflow: auto;
max-height: 260px;
margin: 8px 0 0;
padding: 10px;
border-radius: 10px;
background: var(--surface-3);
white-space: pre-wrap;
}
.feedback-history {
display: grid;
gap: 8px;
margin: 10px 0 0;
padding-left: 20px;
}
.feedback-target-highlight {
outline: 3px solid var(--sea) !important;
outline-offset: 3px;
}
.lumi-context-menu {
position: fixed;
z-index: 120;
min-width: 180px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
}
.lumi-context-menu button {
display: block;
width: 100%;
padding: 9px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--ink);
text-align: left;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.lumi-context-menu button:hover,
.lumi-context-menu button:focus {
outline: none;
background: var(--surface-2);
}
.checkbox-inline {
display: inline-flex;
gap: 8px;
align-items: center;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));

View File

@ -110,6 +110,19 @@ const {
publishWebEvent,
subscribeWebEvents
} = require("../services/web-events");
const {
addSubmitterComment,
adminUpdateFeedback,
convertFeedbackToTodo,
createFeedback,
feedbackOptions,
getFeedbackForSubmitter,
listFeedbackForAdmin,
listMyFeedback,
listPublicFeedback,
markFeedbackViewed,
notificationSummary
} = require("../services/feedback");
function ensureSessionSecret() {
let secret = getSetting("session_secret");
@ -2566,6 +2579,22 @@ function createWebServer({ loadPlugins, discordClient }) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/feedback", requireAuth, (req, res) => {
try {
const entry = createFeedback(req.body, req.session.user);
res.status(201).json({
ok: true,
id: entry.id,
message: "Feedback submitted. You can review it from the Feedback page."
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get("/api/feedback/notifications", requireAuth, (req, res) => {
res.set("Cache-Control", "no-store");
res.json(notificationSummary(req.session.user.id));
});
app.use((req, res, next) => {
if (req.method !== "POST" || !isDestructivePath(req.path)) return next();
const confirmation = consumeConfirmation(req, normalizeAction(req.originalUrl));
@ -2609,6 +2638,8 @@ function createWebServer({ loadPlugins, discordClient }) {
res.locals.userInitial = req.session.user?.username
? req.session.user.username.charAt(0).toUpperCase()
: "";
res.locals.feedbackNotifications = notificationSummary(req.session.user?.id);
res.locals.feedbackOptions = feedbackOptions();
req.session.flash = null;
trackModRole(db, req.session.user);
res.locals.navSections = buildNavSections(
@ -3663,6 +3694,31 @@ function createWebServer({ loadPlugins, discordClient }) {
res.redirect("/profile");
});
app.get("/feedback", requireAuth, (req, res) => {
markFeedbackViewed(req.session.user.id);
res.locals.feedbackNotifications = notificationSummary(req.session.user.id);
const selected = req.query.id
? getFeedbackForSubmitter(String(req.query.id), req.session.user.id)
: null;
res.render("feedback", {
title: "Feedback",
feedbackList: listPublicFeedback({ userId: req.session.user.id }),
myFeedback: listMyFeedback(req.session.user.id),
selectedFeedback: selected,
feedbackOptions: feedbackOptions()
});
});
app.post("/feedback/:id/comment", requireAuth, (req, res) => {
try {
addSubmitterComment(req.params.id, req.body.comment, req.session.user);
setFlash(req, "success", "Comment added.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`);
});
app.get("/health", (req, res) => {
res.set("Cache-Control", "no-store");
res.json({ ok: true, ts: Date.now() });
@ -4922,6 +4978,43 @@ function createWebServer({ loadPlugins, discordClient }) {
res.send(lines.join("\n"));
});
app.get("/admin/feedback", requireRole("admin"), (req, res) => {
res.render("admin-feedback", {
title: "Feedback review",
feedbackItems: listFeedbackForAdmin(req.query),
filters: {
status: req.query.status || "",
category: req.query.category || "",
severity: req.query.severity || "",
scope: req.query.scope || "",
submitter: req.query.submitter || "",
needs_action: req.query.needs_action || "",
sort: req.query.sort || "last_activity"
},
feedbackOptions: feedbackOptions()
});
});
app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => {
try {
adminUpdateFeedback(req.params.id, req.body, req.session.user);
setFlash(req, "success", "Feedback updated.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.post("/admin/feedback/:id/todo", requireRole("admin"), (req, res) => {
try {
convertFeedbackToTodo(req.params.id, req.body.todo_text, req.session.user);
setFlash(req, "success", "Feedback converted to TODO.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.get("/admin/privileges", requireRole("admin"), async (req, res) => {
const discord = await buildDiscordPrivileges(discordClient);
const twitch = await buildTwitchPrivileges();
@ -5829,6 +5922,13 @@ function collectNavItems(user, pluginNav, currentPath) {
authRequired: true,
section: "community"
},
{
label: "Feedback",
path: "/feedback",
role: "public",
authRequired: true,
section: "community"
},
{ label: "Mods List", path: "/moderator", role: "mod", section: "moderation" },
{ label: "Admin", path: "/admin", role: "admin", section: "admin" },
{
@ -5855,6 +5955,7 @@ function collectNavItems(user, pluginNav, currentPath) {
role: "admin",
section: "admin"
},
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
{

View File

@ -0,0 +1,212 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<%- include("partials/page-header", {
eyebrow: "Administration",
pageTitle: "Feedback review",
description: "Review core feedback, reply to submitters, record private work notes, and convert useful reports into TODO entries."
}) %>
<form method="get" action="/admin/feedback" class="log-controls feedback-admin-filters">
<label>
<span>Status</span>
<select name="status">
<option value="">Active statuses</option>
<% feedbackOptions.statuses.forEach((status) => { %>
<option value="<%= status.value %>" <%= filters.status === status.value ? "selected" : "" %>><%= status.label %></option>
<% }) %>
</select>
</label>
<label>
<span>Category</span>
<select name="category">
<option value="">All categories</option>
<% feedbackOptions.categories.forEach((category) => { %>
<option value="<%= category.value %>" <%= filters.category === category.value ? "selected" : "" %>><%= category.label %></option>
<% }) %>
</select>
</label>
<label>
<span>Severity</span>
<select name="severity">
<option value="">All severities</option>
<% feedbackOptions.severities.forEach((severity) => { %>
<option value="<%= severity.value %>" <%= filters.severity === severity.value ? "selected" : "" %>><%= severity.label %></option>
<% }) %>
</select>
</label>
<label>
<span>Scope</span>
<select name="scope">
<option value="">All scopes</option>
<% feedbackOptions.scopes.forEach((scope) => { %>
<option value="<%= scope.value %>" <%= filters.scope === scope.value ? "selected" : "" %>><%= scope.label %></option>
<% }) %>
</select>
</label>
<label>
<span>Submitter</span>
<input name="submitter" value="<%= filters.submitter %>" placeholder="Name or user ID" />
</label>
<label>
<span>Sort</span>
<select name="sort">
<% [
["last_activity", "Last activity"],
["newest", "Newest"],
["oldest", "Oldest"],
["severity", "Severity"],
["status", "Status"]
].forEach(([value, label]) => { %>
<option value="<%= value %>" <%= filters.sort === value ? "selected" : "" %>><%= label %></option>
<% }) %>
</select>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="needs_action" value="1" <%= filters.needs_action === "1" ? "checked" : "" %> />
<span>Needs admin action</span>
</label>
<button class="button subtle" type="submit">Filter</button>
<a class="button subtle" href="/admin/feedback">Reset</a>
</form>
</section>
<section class="card">
<div class="section-header">
<div>
<h2>Feedback queue</h2>
<p class="hint"><%= feedbackItems.length %> item(s) shown.</p>
</div>
</div>
<% if (!feedbackItems.length) { %>
<div class="empty-state">No feedback matches this filter.</div>
<% } else { %>
<div class="feedback-admin-list">
<% feedbackItems.forEach((item) => { %>
<details class="feedback-admin-item" <%= item.status === "new" || item.status === "needs_more_context" ? "open" : "" %>>
<summary>
<span>
<strong><%= item.summary %></strong>
<small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %></small>
</span>
<span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span>
</summary>
<div class="feedback-admin-body">
<div class="feedback-detail-grid">
<div><span>Submitter</span><strong><%= item.submitter_name || item.submitter_id %></strong></div>
<div><span>Severity</span><strong><%= item.severity_label %></strong></div>
<div><span>Created</span><strong><%= new Date(item.created_at).toLocaleString() %></strong></div>
<div><span>Last activity</span><strong><%= new Date(item.last_activity_at).toLocaleString() %></strong></div>
</div>
<div class="feedback-copy-block">
<strong>Description</strong>
<p><%= item.description %></p>
</div>
<% if (item.steps_to_reproduce) { %><div class="feedback-copy-block"><strong>Steps</strong><p><%= item.steps_to_reproduce %></p></div><% } %>
<div class="feedback-two-col">
<div class="feedback-copy-block"><strong>Expected</strong><p><%= item.expected_behavior || "-" %></p></div>
<div class="feedback-copy-block"><strong>Actual</strong><p><%= item.actual_behavior || "-" %></p></div>
</div>
<details class="feedback-metadata">
<summary>Target and diagnostics</summary>
<div class="feedback-two-col">
<pre><%= JSON.stringify(item.target_metadata || {}, null, 2) %></pre>
<pre><%= JSON.stringify(item.diagnostics || {}, null, 2) %></pre>
</div>
<% if (item.current_url) { %><p><strong>URL:</strong> <a href="<%= item.current_url %>"><%= item.current_url %></a></p><% } %>
<% if (item.page_title) { %><p><strong>Page title:</strong> <%= item.page_title %></p><% } %>
</details>
<div class="feedback-comments">
<h3>Comments and notes</h3>
<% if (!item.comments.length) { %>
<p class="hint">No comments or notes yet.</p>
<% } %>
<% item.comments.forEach((comment) => { %>
<article class="feedback-comment feedback-comment-<%= comment.kind %>">
<strong><%= comment.kind_label %></strong>
<p><%= comment.body %></p>
<span class="hint"><%= comment.actor_name || comment.actor_id %> · <%= new Date(comment.created_at).toLocaleString() %><%= comment.visible_to_submitter ? "" : " · private" %></span>
</article>
<% }) %>
</div>
<details class="feedback-metadata">
<summary>Status history</summary>
<ul class="feedback-history">
<% item.history.forEach((history) => { %>
<li><strong><%= history.status_label %></strong> <span class="hint"><%= new Date(history.created_at).toLocaleString() %> · <%= history.actor_name || history.actor_id || "System" %></span><% if (history.note) { %><p><%= history.note %></p><% } %></li>
<% }) %>
</ul>
</details>
<form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form">
<div class="field">
<label>Status</label>
<select name="status">
<% feedbackOptions.statuses.forEach((status) => { %>
<option value="<%= status.value %>" <%= item.status === status.value ? "selected" : "" %>><%= status.label %></option>
<% }) %>
</select>
</div>
<div class="field">
<label>Category</label>
<select name="category">
<% feedbackOptions.categories.forEach((category) => { %>
<option value="<%= category.value %>" <%= item.category === category.value ? "selected" : "" %>><%= category.label %></option>
<% }) %>
</select>
</div>
<div class="field">
<label>Severity</label>
<select name="severity">
<% feedbackOptions.severities.forEach((severity) => { %>
<option value="<%= severity.value %>" <%= item.severity === severity.value ? "selected" : "" %>><%= severity.label %></option>
<% }) %>
</select>
</div>
<div class="field">
<label>Issue link</label>
<input name="linked_issue" value="<%= item.linked_issue || "" %>" placeholder="Optional URL or issue ID" />
</div>
<div class="field">
<label>Correction link</label>
<input name="linked_correction" value="<%= item.linked_correction || "" %>" placeholder="Optional OKF/correction link" />
</div>
<div class="field full">
<label>Status note</label>
<input name="status_note" placeholder="Optional note for status history" />
</div>
<div class="field full">
<label>Reply visible to submitter</label>
<textarea name="admin_reply" rows="3"><%= item.admin_reply || "" %></textarea>
</div>
<div class="field full">
<label>Private work note</label>
<textarea name="work_note" rows="3" placeholder="Only admins can see this note."></textarea>
</div>
<div class="field full button-group">
<button class="button" type="submit">Save review</button>
</div>
</form>
<details class="feedback-metadata">
<summary>Convert to TODO</summary>
<form method="post" action="/admin/feedback/<%= item.id %>/todo" class="form-grid">
<div class="field full">
<label>TODO text</label>
<textarea name="todo_text" rows="4">[Feedback <%= item.id.slice(0, 8) %>] <%= item.summary %><%= item.current_url ? ` (${item.current_url})` : "" %>: <%= item.description.slice(0, 300) %></textarea>
<span class="hint">This appends to a Feedback-Derived TODOs section in TODO.md. It does not create issues automatically.</span>
</div>
<div class="field full">
<button class="button subtle" type="submit">Convert to TODO</button>
</div>
</form>
</details>
</div>
</details>
<% }) %>
</div>
<% } %>
</section>
<%- include("partials/layout-bottom") %>

136
src/web/views/feedback.ejs Normal file
View File

@ -0,0 +1,136 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<%- include("partials/page-header", {
eyebrow: "Community",
pageTitle: "Feedback",
description: "Submit one clear report per issue and track your own feedback."
}) %>
<div class="feedback-page-actions">
<button type="button" class="button" data-feedback-open data-feedback-scope="page">Send feedback</button>
<p class="hint">Small, specific reports are easier to review than broad combined lists.</p>
</div>
</section>
<section class="card feedback-notification-summary">
<h2>Your feedback notifications</h2>
<div class="feedback-badge-row">
<span class="feedback-status-pill solved">Solved <strong><%= feedbackNotifications.solved || 0 %></strong></span>
<span class="feedback-status-pill needs-context">Needs more context <strong><%= feedbackNotifications.needs_context || 0 %></strong></span>
<span class="feedback-status-pill not-worked">Not being worked on <strong><%= feedbackNotifications.not_worked || 0 %></strong></span>
</div>
<p class="hint">Opening this page marks current feedback updates as viewed.</p>
</section>
<section class="card">
<h2>My feedback</h2>
<% if (!myFeedback.length) { %>
<div class="empty-state">You have not submitted feedback yet.</div>
<% } else { %>
<div class="feedback-list">
<% myFeedback.forEach((item) => { %>
<article class="feedback-row <%= selectedFeedback && selectedFeedback.id === item.id ? 'is-selected' : '' %>">
<div>
<h3><a href="/feedback?id=<%= encodeURIComponent(item.id) %>"><%= item.summary %></a></h3>
<p class="hint"><%= item.category_label %> · <%= item.scope_label_display %> · <%= new Date(item.updated_at).toLocaleString() %></p>
</div>
<span class="status-indicator feedback-status-<%= item.status %>" title="<%= item.status_help %>"><%= item.status_label %></span>
</article>
<% }) %>
</div>
<% } %>
</section>
<% if (selectedFeedback) { %>
<section class="card feedback-detail">
<div class="section-header">
<div>
<h2><%= selectedFeedback.summary %></h2>
<p class="hint"><%= selectedFeedback.status_label %>: <%= selectedFeedback.status_help %></p>
</div>
<span class="status-indicator feedback-status-<%= selectedFeedback.status %>"><%= selectedFeedback.status_label %></span>
</div>
<div class="feedback-detail-grid">
<div><span>Category</span><strong><%= selectedFeedback.category_label %></strong></div>
<div><span>Severity</span><strong><%= selectedFeedback.severity_label %></strong></div>
<div><span>Scope</span><strong><%= selectedFeedback.scope_label_display %></strong></div>
<div><span>Submitted</span><strong><%= new Date(selectedFeedback.created_at).toLocaleString() %></strong></div>
</div>
<div class="feedback-copy-block">
<strong>Description</strong>
<p><%= selectedFeedback.description %></p>
</div>
<% if (selectedFeedback.steps_to_reproduce) { %>
<div class="feedback-copy-block"><strong>Steps to reproduce</strong><p><%= selectedFeedback.steps_to_reproduce %></p></div>
<% } %>
<% if (selectedFeedback.expected_behavior || selectedFeedback.actual_behavior) { %>
<div class="feedback-two-col">
<div class="feedback-copy-block"><strong>Expected</strong><p><%= selectedFeedback.expected_behavior || "-" %></p></div>
<div class="feedback-copy-block"><strong>Actual</strong><p><%= selectedFeedback.actual_behavior || "-" %></p></div>
</div>
<% } %>
<% if (selectedFeedback.admin_reply) { %>
<div class="callout success"><strong>Admin reply</strong><p><%= selectedFeedback.admin_reply %></p></div>
<% } %>
<h3>Conversation</h3>
<% if (!selectedFeedback.comments.length) { %>
<p class="hint">No comments yet.</p>
<% } else { %>
<div class="feedback-comments">
<% selectedFeedback.comments.forEach((comment) => { %>
<article class="feedback-comment feedback-comment-<%= comment.kind %>">
<strong><%= comment.kind_label %></strong>
<p><%= comment.body %></p>
<span class="hint"><%= comment.actor_name || "User" %> · <%= new Date(comment.created_at).toLocaleString() %></span>
</article>
<% }) %>
</div>
<% } %>
<form method="post" action="/feedback/<%= selectedFeedback.id %>/comment" class="form-grid">
<div class="field full">
<label>Add more context</label>
<textarea name="comment" rows="4" required></textarea>
<span class="hint">Use this when an admin asks for more detail or you have extra context.</span>
</div>
<div class="field full">
<button type="submit" class="button">Add comment</button>
</div>
</form>
</section>
<% } %>
<section class="card">
<h2>Community feedback overview</h2>
<p class="hint">This list hides submitter identity and detailed diagnostics.</p>
<% if (!feedbackList.length) { %>
<div class="empty-state">No feedback has been submitted yet.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Summary</th>
<th>Category</th>
<th>Scope</th>
<th>Status</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<% feedbackList.forEach((item) => { %>
<tr>
<td>
<%= item.summary %>
<% if (item.is_mine) { %><span class="badge">Mine</span><% } %>
</td>
<td><%= item.category_label %></td>
<td><%= item.scope_label_display %></td>
<td><span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span></td>
<td><%= new Date(item.updated_at).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>
<%- include("partials/layout-bottom") %>

View File

@ -11,6 +11,94 @@
</div>
</div>
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
<% if (user) { %>
<% const feedbackNotice = typeof feedbackNotifications !== "undefined" ? feedbackNotifications : { total: 0, solved: 0, needs_context: 0, not_worked: 0 }; %>
<% const feedbackOptionData = typeof feedbackOptions !== "undefined" ? feedbackOptions : {}; %>
<button class="feedback-floating-button" type="button" data-feedback-open data-feedback-scope="page" aria-label="Send feedback">
Feedback
<% if (feedbackNotice && feedbackNotice.total) { %>
<span class="feedback-floating-badge" aria-label="<%= feedbackNotice.total %> feedback update(s)"><%= feedbackNotice.total %></span>
<% } %>
</button>
<div
class="modal-backdrop feedback-modal-backdrop"
data-feedback-modal
aria-hidden="true"
data-feedback-options="<%= JSON.stringify(feedbackOptionData || {}) %>"
>
<div class="modal feedback-modal" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
<div class="modal-header">
<h3 id="feedback-modal-title">Send feedback</h3>
<button type="button" class="icon-button" data-feedback-close aria-label="Close feedback">&times;</button>
</div>
<form class="form-grid" data-feedback-form>
<input type="hidden" name="target_metadata" data-feedback-target-metadata />
<input type="hidden" name="current_url" data-feedback-current-url />
<input type="hidden" name="page_title" data-feedback-page-title />
<input type="hidden" name="diagnostics" data-feedback-diagnostics />
<div class="field full">
<label>Short summary</label>
<input name="summary" maxlength="140" required placeholder="Example: Save button does nothing" />
</div>
<div class="field">
<label>Category</label>
<select name="category" data-feedback-category required></select>
</div>
<div class="field">
<label>Severity</label>
<select name="severity" data-feedback-severity required></select>
</div>
<div class="field">
<label>Feedback is about</label>
<select name="scope_type" data-feedback-scope-type required></select>
</div>
<div class="field">
<label>Scope label</label>
<input name="scope_label" data-feedback-scope-label placeholder="Auto-detected or custom" />
</div>
<div class="field full">
<label>Description</label>
<textarea name="description" rows="5" required placeholder="Describe one specific issue or suggestion."></textarea>
<span class="hint">Please submit one feedback item per issue. Do not include passwords, tokens, or private messages.</span>
</div>
<div class="field full">
<label>Steps to reproduce <span class="hint">optional</span></label>
<textarea name="steps_to_reproduce" rows="3"></textarea>
</div>
<div class="field">
<label>Expected behavior <span class="hint">optional</span></label>
<textarea name="expected_behavior" rows="3"></textarea>
</div>
<div class="field">
<label>Actual behavior <span class="hint">optional</span></label>
<textarea name="actual_behavior" rows="3"></textarea>
</div>
<div class="field full">
<label class="checkbox-inline">
<input type="checkbox" data-feedback-include-browser />
<span>Include browser and viewport details</span>
</label>
<span class="hint">This attaches your browser name, page size, language, URL, and page title. Field values are not collected.</span>
</div>
<div class="field full feedback-submit-status" data-feedback-status aria-live="polite"></div>
<div class="modal-actions">
<button type="button" class="button subtle" data-feedback-close>Cancel</button>
<button type="submit" class="button">Submit feedback</button>
</div>
</form>
</div>
</div>
<div class="lumi-context-menu" data-lumi-context-menu hidden role="menu" aria-label="Page actions">
<button type="button" role="menuitem" data-context-action="back">Back</button>
<button type="button" role="menuitem" data-context-action="forward">Forward</button>
<button type="button" role="menuitem" data-context-action="copy">Copy</button>
<button type="button" role="menuitem" data-context-action="cut">Cut</button>
<button type="button" role="menuitem" data-context-action="paste">Paste</button>
<button type="button" role="menuitem" data-context-action="link">Link to here</button>
<button type="button" role="menuitem" data-context-action="reload">Hard reload</button>
<button type="button" role="menuitem" data-context-action="feedback">Feedback</button>
</div>
<% } %>
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
<div class="modal-header">