Add core feedback workflow hardening

This commit is contained in:
Franz Rolfsvaag 2026-06-18 10:54:34 +02:00
parent 9c27d12445
commit 82b3984d14
11 changed files with 1892 additions and 167 deletions

171
TODO.md
View File

@ -125,7 +125,134 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
## Core Feedback System ## 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. Current state on `experimental-feedback-system` as of 2026-06-18: the core feedback system is implemented locally and not yet pushed after the latest context-menu, review action, and screenshot attachment refinements. The detailed checklist below is retained for hardening, but this current-state block is the source of truth.
### Implemented Locally
- Core-level feedback storage in SQLite:
- `feedback_entries`
- `feedback_comments`
- `feedback_status_history`
- `feedback_view_state`
- Logged-in feedback submission through:
- persistent floating feedback button
- site-wide custom context menu
- `/api/feedback`
- `/feedback` under Community navigation for logged-in users.
- `/admin/feedback` under Admin navigation for admin review.
- Generic issue categories, severity values, scope types, and status labels.
- Feedback modal fields for summary, category, severity, scope, description, steps, expected behavior, actual behavior, page URL, page title, clicked-element metadata, and optional browser/viewport diagnostics.
- Element-targeted feedback from right-click context menu with safe metadata:
- selector/path
- tag
- visible text snippet
- aria label/title/role
- nearest label/heading
- page URL/title
- viewport size
- Sensitive form field values are not captured by the element metadata collector.
- Selected feedback target is highlighted while the modal is open and cleared when closed.
- User feedback center:
- public non-identifying feedback summary list
- “My feedback” section
- own-feedback detail view
- submitter comments/follow-up
- admin replies visible to submitter
- Per-user notification counters for solved, needs-context, and not-worked/not-planned outcomes.
- Notification counters reset/update when the user opens `/feedback`.
- Admin review page:
- full feedback details
- submitter identity
- target metadata
- optional diagnostics
- status history
- comments/replies
- private work notes
- filters for status, category, severity, scope, submitter, and needs-action
- filters for plugin/area text and submitted date range
- sorting by newest, oldest, severity, status, and last activity
- status/category/severity updates
- admin reply to submitter
- private admin work note
- request-more-context support through `needs_more_context`
- duplicate/rejected/not-planned/won't-fix/archive/delete statuses
- manual links to issue/correction references
- Admin review actions:
- `Finalize & Close`
- reopen closed feedback
- hard-delete feedback, comments, private notes, status history, and screenshots
- sensitive-data cleanup for screenshots, diagnostics, target metadata, and public admin replies
- feedback rows are collapsed by default
- Moderate submission rate limit.
- Validation against empty/too-short feedback.
- Server-side permission checks for submission, own-feedback details, submitter comments, and admin review.
- EJS escaping and sanitized server-side JSON/text handling for submitted feedback.
- AI Improvement Center remains separate; `wrong_tool_usage` classification already exists there.
- Custom context menu:
- Back
- Forward
- Copy
- Cut
- Paste
- Link to here
- Hard reload
- Feedback
- Context menu availability behavior:
- all actions remain visible
- unavailable actions are greyed out and non-interactive
- Back/Forward availability is history-aware where browser APIs allow
- Copy is available only for selected text or right-clicked images
- Cut is available only for selected text inside editable fields
- Paste is available only when an editable field is active and readable clipboard text exists
- Link to here, Hard reload, and Feedback remain available
- Context menu tooltips:
- show after one second of hover
- explain each action
- use fixed positioning
- clamp to the viewport so they do not overflow or expand the page
- Context menu keyboard behavior:
- arrow-key navigation
- Home/End navigation
- Enter/Space activation
- Tab focus wrapping while open
- Scope label/target field:
- displayed as a generated read-only “Detected target” field
- updates when “Feedback is about” changes
- is regenerated server-side and does not trust submitted free text
- Optional screenshot support:
- explicit opt-in only
- supports image upload
- supports pasted screenshots from the clipboard where browsers allow
- avoids browser tab/window picker capture so the browser prompt is not submitted
- shows an attached screenshot preview
- lets users remove the screenshot before submitting
- stores screenshots under `data/feedback/screenshots`
- exposes screenshots only to the submitter or admins through authenticated routes
- General protected attachments:
- supports up to three optional PNG/JPEG/WebP/PDF/text files per feedback item
- stores attachments under `data/feedback/attachments`
- exposes attachments only to the submitter or admins through authenticated routes
- supports admin cleanup/removal
- Public discussion on existing feedback:
- logged-in users can open public feedback detail without private diagnostics or attachments
- logged-in users can mark existing feedback as affecting them too
- logged-in users can add public context comments to existing feedback
### Partially Implemented
- Optional diagnostics exist for browser/user-agent, viewport, language, current URL, page title, selected scope, target metadata, screenshot attachment source, and opt-in sanitized page structure snapshots.
- Admin filters cover status/category/severity/scope/plugin-area/submitter/date/needs-action. Richer custom-scope filters are not implemented.
- OKF correction conversion is deferred for a later pass. Task tracking remains manual and outside the feedback feature.
- Duplicate handling includes lightweight similar-feedback suggestions from summary, description, scope, category, and page path; “also affected” counts; and public context comments, but not full duplicate merging.
- Feedback data is in SQLite under `data/app.db`, which is preserved by the update system through the existing protected `data/` path. Screenshot files are stored under `data/feedback/screenshots`, also covered by the protected `data/` path.
### Remaining Work
- Full scroll-page or automatic current-tab screenshot capture is not implemented. Current browser APIs require prompts for page capture, so the implemented path is explicit upload/paste.
- Full duplicate merge workflow and richer duplicate detection beyond lightweight summary/description/scope/category/page matching.
- Richer custom scope targeting filters.
- OKF correction conversion for documentation/terminology/knowledge feedback, deferred.
- Broader browser testing for image clipboard copy and clipboard-read availability because browser support and permissions vary.
- Implement a general core feedback system available to logged-in users only. - Implement a general core feedback system available to logged-in users only.
- Add a persistent feedback entry point somewhere logical in the UI. - Add a persistent feedback entry point somewhere logical in the UI.
@ -223,13 +350,12 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
### Optional Screenshot Support ### Optional Screenshot Support
- Add optional browser-generated screenshot support. - Add optional screenshot attachment support without browser tab/window picker prompts.
- Screenshots must be opt-in by the submitting user. - Screenshots must be opt-in by the submitting user.
- Do not require screenshots for feedback submission. - Do not require screenshots for feedback submission.
- Support full-page screenshots where technically possible. - Support uploaded screenshot image files.
- Support cropped/section-only screenshots where technically possible. - Support pasted screenshots from the clipboard where browsers allow it.
- Temporarily hide feedback buttons, feedback modals, context menus, and feedback overlays while capturing screenshots. - Consider full-page screenshots only if a safe DOM-rendering implementation or browser extension path is added later.
- Restore hidden feedback UI immediately after capture.
- Store screenshots only when explicitly attached by the user. - Store screenshots only when explicitly attached by the user.
- Clearly warn users not to include sensitive information in screenshots. - Clearly warn users not to include sensitive information in screenshots.
- Allow users to remove an attached screenshot before submitting. - Allow users to remove an attached screenshot before submitting.
@ -343,22 +469,8 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- mark duplicate - mark duplicate
- archive - archive
- delete - delete
- convert to TODO
- convert to issue
- convert to OKF correction where relevant - convert to OKF correction where relevant
- link to existing TODO/issue/correction - link to existing 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 ### OKF and AI Improvement Integration
@ -377,7 +489,7 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- Encourage one feedback item per issue. - Encourage one feedback item per issue.
- Add validation to prevent empty or extremely vague feedback. - Add validation to prevent empty or extremely vague feedback.
- Add optional duplicate detection based on similar title, scope, and page. - 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. - If similar existing feedback exists, suggest adding a public comment or marking that it affects the user too instead of creating a duplicate.
- Allow admins to delete or archive abusive/spam feedback. - Allow admins to delete or archive abusive/spam feedback.
### Data Storage and Preservation ### Data Storage and Preservation
@ -395,7 +507,7 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- assigned_admin_id if used - assigned_admin_id if used
- status history - status history
- comments/replies - comments/replies
- linked TODOs/issues/corrections - linked issues/corrections
### Privacy and Safety ### Privacy and Safety
@ -414,18 +526,25 @@ First implementation pass completed 2026-06-18. Remaining feedback work is mainl
- Logged-in users can submit targeted feedback from the custom right-click context menu. - 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. - 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. - 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. - Users can optionally attach screenshots by upload or clipboard paste.
- `/feedback` appears under the Community navbar section for logged-in users. - `/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. - `/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. - 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 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. - Admins can reply, add work notes, request more context, change statuses, finalize and close, reopen closed feedback, and permanently delete feedback.
- Feedback data is preserved across updates and included in protected user data. - 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. - AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification.
## Done ## 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-18: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments.
- 2026-06-18: Fixed experimental feedback review buttons to use the stable save endpoint for Finalize & Close/Reopen and added a reusable Lumi `button-group` utility, including the `.field.button-group` specificity case, so review buttons align horizontally.
- 2026-06-18: Moved feedback screenshot guidance above Description, centered feedback screenshot/form action rows, and moved feedback modal sizing overrides into the later-loaded Lumi component layer so generic `.modal` styles no longer win.
- 2026-06-18: Added feedback “also affects me” records, user-facing similar-feedback suggestions before submit, admin plugin/area/date filters, sensitive-data cleanup controls, and keyboard navigation for the custom context menu; dropped external issue creation from the feedback roadmap and left OKF conversion deferred.
- 2026-06-18: Added protected general feedback attachments, protected attachment downloads, admin attachment cleanup/removal, viewer-safe public feedback details, and public context comments on existing feedback.
- 2026-06-18: Added opt-in sanitized page structure diagnostics for feedback, excluding sensitive form values and capping stored snapshot text server-side.
- 2026-06-18: Updated the experimental feedback context menu locally so all actions remain visible with disabled unavailable states, Back/Forward/Copy/Cut/Paste availability is context-aware, image/text copy and input-only cut/paste rules are enforced where browsers allow, and one-second viewport-clamped tooltips explain each action.
- 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, 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: 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: 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. - 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.

View File

@ -25,6 +25,9 @@ try {
db.prepare( db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run("admin-1", "AdminUser", now, now); ).run("admin-1", "AdminUser", now, now);
db.prepare(
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
).run("user-2", "SecondUser", now, now);
const entry = feedback.createFeedback({ const entry = feedback.createFeedback({
summary: "Save button fails", summary: "Save button fails",
@ -47,22 +50,40 @@ try {
diagnostics: { diagnostics: {
user_agent: "verification-agent", user_agent: "verification-agent",
viewport: "1200x800", viewport: "1200x800",
screenshot_mode: "target",
hidden: "must not persist" hidden: "must not persist"
} }
}, { id: "user-1" }); }, { id: "user-1" }, {
screenshot: {
path: "feedback/screenshots/test-shot.png",
mime: "image/png",
size: 2048
}
});
assert.equal(entry.status, "new"); assert.equal(entry.status, "new");
assert.equal(entry.scope_label, "Clicked element: Save settings");
assert.equal(entry.target_metadata.secret, undefined); assert.equal(entry.target_metadata.secret, undefined);
assert.equal(entry.diagnostics.hidden, undefined); assert.equal(entry.diagnostics.hidden, undefined);
assert.equal(entry.screenshot.mime, "image/png");
assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true); assert.equal(feedback.listPublicFeedback({ userId: "user-1" })[0].is_mine, true);
assert.equal(feedback.listMyFeedback("user-1").length, 1); assert.equal(feedback.listMyFeedback("user-1").length, 1);
assert.equal(feedback.supportFeedback(entry.id, { id: "user-2" }), 1);
assert.equal(feedback.listPublicFeedback({ userId: "user-2" })[0].supported_by_me, true);
feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" }); feedback.addSubmitterComment(entry.id, "This also happens after a hard reload.", { id: "user-1" });
feedback.addSubmitterComment(entry.id, "I see this too on the same settings page.", { id: "user-2" });
const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1"); const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1");
assert.equal(withComment.comments.length, 1); assert.equal(withComment.comments.length, 2);
assert.equal(withComment.comments[0].kind, "submitter_comment"); assert.equal(withComment.comments[0].kind, "submitter_comment");
assert.equal(withComment.comments[1].kind, "public_comment");
const publicViewerEntry = feedback.getFeedbackForViewer(entry.id, "user-2");
assert.equal(publicViewerEntry.is_mine, false);
assert.equal(publicViewerEntry.screenshot, null);
assert(publicViewerEntry.comments.some((comment) => comment.kind === "public_comment"));
feedback.markFeedbackViewed("user-1"); feedback.markFeedbackViewed("user-1");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
feedback.adminUpdateFeedback(entry.id, { feedback.adminUpdateFeedback(entry.id, {
status: "needs_more_context", status: "needs_more_context",
category: "bug", category: "bug",
@ -79,11 +100,79 @@ try {
const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1"); const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1");
assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false); assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false);
const todoPath = path.join(sandbox, "TODO.md"); feedback.adminUpdateFeedback(entry.id, {
fs.writeFileSync(todoPath, "# TODO\n\n## Done\n"); status: "closed",
feedback.convertFeedbackToTodo(entry.id, "Fix feedback verification issue.", { id: "admin-1" }, todoPath); category: "bug",
assert(fs.readFileSync(todoPath, "utf8").includes("## Feedback-Derived TODOs")); severity: "urgent",
assert(fs.readFileSync(todoPath, "utf8").includes("Fix feedback verification issue.")); status_note: "Finalized after verification."
}, { id: "admin-1" });
assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "closed");
feedback.adminUpdateFeedback(entry.id, {
status: "reviewed",
category: "bug",
severity: "urgent",
status_note: "Reopened after verification."
}, { id: "admin-1" });
assert.equal(feedback.getFeedbackForAdmin(entry.id).status, "reviewed");
assert(feedback.findSimilarFeedback({
summary: "Settings save button does nothing",
category: "bug",
scope_type: "element",
current_url: "http://localhost/admin/settings"
}, { userId: "user-2" }).some((match) => match.id === entry.id));
assert(feedback.findSimilarFeedback({
summary: "Controls are weird",
description: "Clicking the save button does not apply changes after editing settings.",
category: "bug",
scope_type: "element",
current_url: "http://localhost/admin/settings"
}, { userId: "user-2" }).some((match) => match.id === entry.id));
const cleanupEntry = feedback.createFeedback({
summary: "Sensitive screenshot cleanup",
category: "bug",
severity: "minor",
scope_type: "page",
description: "This report exists to verify cleanup behavior.",
current_url: "http://localhost/admin/feedback",
page_title: "Feedback review",
diagnostics: { user_agent: "sensitive-agent" }
}, { id: "user-1" }, {
screenshot: {
path: "feedback/screenshots/cleanup-shot.png",
mime: "image/png",
size: 1024
},
attachments: [{
path: "feedback/attachments/cleanup-note.txt",
mime: "text/plain",
size: 128,
original_name: "cleanup-note.txt"
}]
});
assert.equal(feedback.getFeedbackForAdmin(cleanupEntry.id).attachments.length, 1);
let cleanedScreenshot = "";
let cleanedAttachment = "";
feedback.cleanupFeedback(cleanupEntry.id, {
clear_screenshot: "1",
clear_attachments: "1",
clear_diagnostics: "1",
clear_target_metadata: "1"
}, { id: "admin-1" }, {
deleteScreenshot(relativePath) {
cleanedScreenshot = relativePath;
},
deleteAttachment(relativePath) {
cleanedAttachment = relativePath;
}
});
const cleaned = feedback.getFeedbackForAdmin(cleanupEntry.id);
assert.equal(cleaned.screenshot, null);
assert.deepEqual(cleaned.diagnostics, {});
assert.deepEqual(cleaned.target_metadata, {});
assert.equal(cleaned.attachments.length, 0);
assert.equal(cleanedScreenshot, "feedback/screenshots/cleanup-shot.png");
assert.equal(cleanedAttachment, "feedback/attachments/cleanup-note.txt");
const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs"); const feedbackView = path.join(root, "src", "web", "views", "feedback.ejs");
const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs"); const adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs");
@ -113,6 +202,17 @@ try {
}, { filename: feedbackView }); }, { filename: feedbackView });
assert(userRendered.includes("My feedback")); assert(userRendered.includes("My feedback"));
assert(userRendered.includes("Admin reply")); assert(userRendered.includes("Admin reply"));
assert(userRendered.includes("Open screenshot"));
assert(userRendered.includes("Community comment"));
const supporterRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), {
...commonLocals,
user: { id: "user-2", username: "SecondUser" },
title: "Feedback",
feedbackList: feedback.listPublicFeedback({ userId: "user-2" }),
myFeedback: feedback.listMyFeedback("user-2"),
selectedFeedback: null
}, { filename: feedbackView });
assert(supporterRendered.includes("Marked as affecting you too."));
const adminRendered = ejs.render(fs.readFileSync(adminView, "utf8"), { const adminRendered = ejs.render(fs.readFileSync(adminView, "utf8"), {
...commonLocals, ...commonLocals,
@ -120,10 +220,28 @@ try {
userInitial: "A", userInitial: "A",
title: "Feedback review", title: "Feedback review",
feedbackItems: feedback.listFeedbackForAdmin({}), feedbackItems: feedback.listFeedbackForAdmin({}),
filters: { status: "", category: "", severity: "", scope: "", submitter: "", needs_action: "", sort: "last_activity" } filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" }
}, { filename: adminView }); }, { filename: adminView });
assert(adminRendered.includes("Feedback queue")); assert(adminRendered.includes("Feedback queue"));
assert(adminRendered.includes("Convert to TODO")); assert(!adminRendered.includes("Convert useful reports"));
assert(adminRendered.includes("Finalize & Close"));
assert(adminRendered.includes("Delete"));
assert(adminRendered.includes("Open attached screenshot"));
assert(adminRendered.includes("Sensitive data cleanup"));
assert(adminRendered.includes("Remove attachments"));
assert(adminRendered.includes("Plugin/area"));
assert(adminRendered.includes("1 also affected"));
let deletedScreenshot = "";
feedback.deleteFeedback(entry.id, {
deleteScreenshot(relativePath) {
deletedScreenshot = relativePath;
}
});
assert.equal(feedback.getFeedbackForAdmin(entry.id), null);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_comments WHERE feedback_id = ?").get(entry.id).count, 0);
assert.equal(db.prepare("SELECT COUNT(*) AS count FROM feedback_status_history WHERE feedback_id = ?").get(entry.id).count, 0);
assert.equal(deletedScreenshot, "feedback/screenshots/test-shot.png");
console.log("Core feedback system verification passed."); console.log("Core feedback system verification passed.");
} finally { } finally {

View File

@ -166,6 +166,9 @@ function migrate() {
expected_behavior TEXT, expected_behavior TEXT,
actual_behavior TEXT, actual_behavior TEXT,
diagnostics_json TEXT NOT NULL DEFAULT '{}', diagnostics_json TEXT NOT NULL DEFAULT '{}',
screenshot_path TEXT,
screenshot_mime TEXT,
screenshot_size INTEGER,
status TEXT NOT NULL DEFAULT 'new', status TEXT NOT NULL DEFAULT 'new',
admin_reply TEXT, admin_reply TEXT,
assigned_admin_id TEXT, assigned_admin_id TEXT,
@ -209,6 +212,28 @@ function migrate() {
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
last_seen_at INTEGER NOT NULL last_seen_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS feedback_support (
feedback_id TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (feedback_id, user_id)
);
CREATE INDEX IF NOT EXISTS feedback_support_feedback_idx ON feedback_support (feedback_id, created_at);
CREATE TABLE IF NOT EXISTS feedback_attachments (
id TEXT PRIMARY KEY,
feedback_id TEXT NOT NULL,
storage_path TEXT NOT NULL,
original_name TEXT,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'attachment',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS feedback_attachments_feedback_idx ON feedback_attachments (feedback_id, created_at);
`); `);
const columns = db const columns = db
@ -271,6 +296,20 @@ function migrate() {
db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER"); db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER");
} }
const feedbackColumns = db
.prepare("PRAGMA table_info(feedback_entries)")
.all()
.map((column) => column.name);
if (!feedbackColumns.includes("screenshot_path")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_path TEXT");
}
if (!feedbackColumns.includes("screenshot_mime")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_mime TEXT");
}
if (!feedbackColumns.includes("screenshot_size")) {
db.exec("ALTER TABLE feedback_entries ADD COLUMN screenshot_size INTEGER");
}
migrateLegacyUsers(); migrateLegacyUsers();
} }

View File

@ -1,5 +1,3 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { db } = require("./db"); const { db } = require("./db");
@ -49,12 +47,13 @@ const FEEDBACK_STATUSES = Object.freeze([
"rejected", "rejected",
"not_planned", "not_planned",
"wont_fix", "wont_fix",
"closed",
"archived", "archived",
"deleted" "deleted"
]); ]);
const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted")); const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted"));
const SOLVED_STATUSES = new Set(["fixed", "solved"]); const SOLVED_STATUSES = new Set(["fixed", "solved", "closed"]);
const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]); const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]);
const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]); const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]);
const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 }; const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 };
@ -104,6 +103,7 @@ const STATUS_LABELS = Object.freeze({
rejected: "Rejected", rejected: "Rejected",
not_planned: "Not planned", not_planned: "Not planned",
wont_fix: "Won't fix", wont_fix: "Won't fix",
closed: "Closed",
archived: "Archived", archived: "Archived",
deleted: "Deleted" deleted: "Deleted"
}); });
@ -121,23 +121,26 @@ const STATUS_HELP = Object.freeze({
rejected: "Rejected after review.", rejected: "Rejected after review.",
not_planned: "Not planned for implementation.", not_planned: "Not planned for implementation.",
wont_fix: "Reviewed, but will not be changed.", wont_fix: "Reviewed, but will not be changed.",
closed: "Finalized and closed by an administrator.",
archived: "Archived for record keeping.", archived: "Archived for record keeping.",
deleted: "Deleted by an administrator." deleted: "Deleted by an administrator."
}); });
function createFeedback(input, actor) { function createFeedback(input, actor, options = {}) {
if (!actor?.id) { if (!actor?.id) {
throw new Error("Feedback requires a logged-in user."); throw new Error("Feedback requires a logged-in user.");
} }
enforceRateLimit(actor.id); enforceRateLimit(actor.id);
const now = Date.now(); const now = Date.now();
const entry = normalizeFeedbackInput(input); const entry = normalizeFeedbackInput(input);
const screenshot = normalizeScreenshot(options.screenshot);
const attachments = normalizeAttachments(options.attachments);
const id = crypto.randomUUID(); const id = crypto.randomUUID();
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
"INSERT INTO feedback_entries " + "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) " + "(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, screenshot_path, screenshot_mime, screenshot_size, status, created_at, updated_at, last_activity_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)" "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)"
).run( ).run(
id, id,
actor.id, actor.id,
@ -154,10 +157,27 @@ function createFeedback(input, actor) {
entry.expected_behavior, entry.expected_behavior,
entry.actual_behavior, entry.actual_behavior,
JSON.stringify(entry.diagnostics), JSON.stringify(entry.diagnostics),
screenshot.path,
screenshot.mime,
screenshot.size,
now, now,
now, now,
now now
); );
for (const attachment of attachments) {
db.prepare(
"INSERT INTO feedback_attachments (id, feedback_id, storage_path, original_name, mime, size, kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
).run(
crypto.randomUUID(),
id,
attachment.path,
attachment.original_name,
attachment.mime,
attachment.size,
attachment.kind,
now
);
}
addStatusHistory(id, "new", actor.id, "Submitted", now); addStatusHistory(id, "new", actor.id, "Submitted", now);
})(); })();
return getFeedbackForAdmin(id); return getFeedbackForAdmin(id);
@ -170,13 +190,58 @@ function listPublicFeedback({ userId, limit = 100 } = {}) {
"FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " + "FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " +
"ORDER BY last_activity_at DESC LIMIT ?" "ORDER BY last_activity_at DESC LIMIT ?"
) )
.all(limit) .all(limit);
.map((row) => ({ const support = supportSummary(rows.map((row) => row.id), userId);
return rows.map((row) => ({
...decorateLabels(row),
support_count: support.counts.get(row.id) || 0,
supported_by_me: support.mine.has(row.id),
is_mine: userId ? row.submitter_id === userId : false,
submitter_id: undefined
}));
}
function findSimilarFeedback(input = {}, options = {}) {
const summary = cleanText(input.summary, 140);
const description = cleanText(input.description, 6000);
const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "";
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "";
const currentUrl = cleanUrl(input.current_url);
const pagePath = pagePathKey(currentUrl);
if (summary.length < 6 && description.length < 12 && !pagePath) return [];
const rows = db
.prepare(
"SELECT id, submitter_id, summary, description, category, severity, scope_type, scope_label, current_url, page_title, status, created_at, updated_at, last_activity_at " +
"FROM feedback_entries WHERE deleted_at IS NULL AND status NOT IN ('deleted', 'closed', 'solved', 'fixed', 'archived') " +
"ORDER BY last_activity_at DESC LIMIT 150"
)
.all();
const queryTokens = tokenSet(`${summary} ${description}`);
const matches = rows
.map((row) => {
const rowTokens = tokenSet(`${row.summary} ${row.description || ""} ${row.scope_label || ""} ${row.page_title || ""}`);
const samePath = pagePath && pagePath === pagePathKey(row.current_url);
const score =
jaccardScore(queryTokens, rowTokens) +
(scopeType && row.scope_type === scopeType ? 0.25 : 0) +
(category && row.category === category ? 0.15 : 0) +
(samePath ? 0.35 : 0);
return { row, score };
})
.filter(({ score }) => score >= 0.32)
.sort((a, b) => b.score - a.score)
.slice(0, Math.min(Number(options.limit || 5), 10))
.map(({ row, score }) => ({
...decorateLabels(row), ...decorateLabels(row),
is_mine: userId ? row.submitter_id === userId : false, match_score: Math.round(score * 100) / 100,
submitter_id: undefined submitter_id: undefined
})); }));
return rows; const support = supportSummary(matches.map((row) => row.id), options.userId);
return matches.map((row) => ({
...row,
support_count: support.counts.get(row.id) || 0,
supported_by_me: support.mine.has(row.id)
}));
} }
function listMyFeedback(userId) { function listMyFeedback(userId) {
@ -197,9 +262,30 @@ function getFeedbackForSubmitter(id, userId) {
return hydrateFeedback(row, { admin: false }); return hydrateFeedback(row, { admin: false });
} }
function getFeedbackForViewer(id, userId) {
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
if (!row || row.deleted_at || row.status === "deleted") {
return null;
}
const entry = hydrateFeedback(row, { admin: false });
entry.is_mine = Boolean(userId && row.submitter_id === userId);
const support = supportSummary([row.id], userId);
entry.support_count = support.counts.get(row.id) || 0;
entry.supported_by_me = support.mine.has(row.id);
if (!entry.is_mine) {
entry.screenshot = null;
entry.attachments = [];
}
return entry;
}
function getFeedbackForAdmin(id) { function getFeedbackForAdmin(id) {
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id); const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
return row ? hydrateFeedback(row, { admin: true }) : null; if (!row) return null;
const entry = hydrateFeedback(row, { admin: true });
const support = supportSummary([row.id]);
entry.support_count = support.counts.get(row.id) || 0;
return entry;
} }
function listFeedbackForAdmin(filters = {}) { function listFeedbackForAdmin(filters = {}) {
@ -224,6 +310,22 @@ function listFeedbackForAdmin(filters = {}) {
where.push("feedback_entries.scope_type = ?"); where.push("feedback_entries.scope_type = ?");
params.push(filters.scope); params.push(filters.scope);
} }
if (filters.area) {
where.push(
"(lower(feedback_entries.scope_label) LIKE lower(?) OR lower(feedback_entries.current_url) LIKE lower(?) OR lower(feedback_entries.page_title) LIKE lower(?))"
);
params.push(`%${filters.area}%`, `%${filters.area}%`, `%${filters.area}%`);
}
const from = parseDateBoundary(filters.date_from, "start");
if (from) {
where.push("feedback_entries.created_at >= ?");
params.push(from);
}
const to = parseDateBoundary(filters.date_to, "end");
if (to) {
where.push("feedback_entries.created_at <= ?");
params.push(to);
}
if (filters.submitter) { if (filters.submitter) {
where.push( where.push(
"(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))" "(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))"
@ -240,34 +342,54 @@ function listFeedbackForAdmin(filters = {}) {
last_activity: "feedback_entries.last_activity_at DESC", last_activity: "feedback_entries.last_activity_at DESC",
newest: "feedback_entries.created_at DESC" newest: "feedback_entries.created_at DESC"
}[filters.sort || "last_activity"]; }[filters.sort || "last_activity"];
return db const rows = db
.prepare( .prepare(
"SELECT feedback_entries.*, user_profiles.internal_username AS submitter_name " + "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 " + "FROM feedback_entries LEFT JOIN user_profiles ON user_profiles.id = feedback_entries.submitter_id " +
`WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250` `WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250`
) )
.all(...params) .all(...params);
.map((row) => hydrateFeedback(row, { admin: true })); const support = supportSummary(rows.map((row) => row.id));
return rows.map((row) => {
const entry = hydrateFeedback(row, { admin: true });
entry.support_count = support.counts.get(row.id) || 0;
return entry;
});
} }
function addSubmitterComment(id, body, actor) { function addSubmitterComment(id, body, actor) {
const entry = getFeedbackForSubmitter(id, actor?.id); const row = db.prepare("SELECT id, submitter_id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id);
if (!entry) { if (!row || row.deleted_at || row.status === "deleted" || !actor?.id) {
throw new Error("Feedback item was not found."); throw new Error("Feedback item was not found.");
} }
const comment = cleanText(body, 4000); const comment = cleanText(body, 4000);
if (comment.length < 3) { if (comment.length < 3) {
throw new Error("Comment is too short."); throw new Error("Comment is too short.");
} }
const kind = row.submitter_id === actor.id ? "submitter_comment" : "public_comment";
const now = Date.now(); const now = Date.now();
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'submitter_comment', ?, 1, ?)" "INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, ?, ?, 1, ?)"
).run(id, actor.id, comment, now); ).run(id, actor.id, kind, comment, now);
touchFeedback(id, now); touchFeedback(id, now);
})(); })();
} }
function supportFeedback(id, actor) {
if (!actor?.id) {
throw new Error("Support requires a logged-in user.");
}
const row = db.prepare("SELECT id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id);
if (!row || row.deleted_at || row.status === "deleted") {
throw new Error("Feedback item was not found.");
}
db.prepare(
"INSERT OR IGNORE INTO feedback_support (feedback_id, user_id, created_at) VALUES (?, ?, ?)"
).run(id, actor.id, Date.now());
return supportSummary([id], actor.id).counts.get(id) || 0;
}
function adminUpdateFeedback(id, input, actor) { function adminUpdateFeedback(id, input, actor) {
const current = getFeedbackForAdmin(id); const current = getFeedbackForAdmin(id);
if (!current) { if (!current) {
@ -314,41 +436,97 @@ function adminUpdateFeedback(id, input, actor) {
return getFeedbackForAdmin(id); return getFeedbackForAdmin(id);
} }
function convertFeedbackToTodo(id, body, actor, todoPath = path.join(__dirname, "..", "..", "TODO.md")) { function deleteFeedback(id, options = {}) {
const entry = getFeedbackForAdmin(id); const current = db.prepare("SELECT id, screenshot_path FROM feedback_entries WHERE id = ?").get(id);
if (!entry) { if (!current) {
throw new Error("Feedback item was not found."); throw new Error("Feedback item was not found.");
} }
const text = cleanText(body, 4000) || defaultTodoText(entry); const attachments = attachmentsFor(id);
if (!text) { db.transaction(() => {
throw new Error("TODO text is required."); db.prepare("DELETE FROM feedback_comments WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_status_history WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_support WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id);
db.prepare("DELETE FROM feedback_entries WHERE id = ?").run(id);
})();
if (current.screenshot_path && typeof options.deleteScreenshot === "function") {
options.deleteScreenshot(current.screenshot_path);
} }
const current = fs.existsSync(todoPath) ? fs.readFileSync(todoPath, "utf8") : "# TODO\n\n## Done\n"; if (typeof options.deleteAttachment === "function") {
const section = "## Feedback-Derived TODOs"; attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path));
const bullet = text }
.split(/\r?\n/) return current;
.map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`)) }
.join("\n");
let next = current; function cleanupFeedback(id, input = {}, actor, options = {}) {
if (!current.includes(section)) { const current = getFeedbackForAdmin(id);
next = current.replace(/\n## Done\b/, `\n${section}\n\n${bullet}\n\n## Done`); if (!current) {
if (next === current) { throw new Error("Feedback item was not found.");
next = `${current.replace(/\s*$/, "")}\n\n${section}\n\n${bullet}\n`; }
const changes = [];
const sets = [];
if (input.clear_screenshot === "1" && current.screenshot?.path) {
sets.push("screenshot_path = NULL", "screenshot_mime = NULL", "screenshot_size = NULL");
changes.push("screenshot");
}
if (input.clear_diagnostics === "1") {
sets.push("diagnostics_json = '{}'");
changes.push("diagnostics");
}
if (input.clear_target_metadata === "1") {
sets.push("target_metadata_json = '{}'");
changes.push("target metadata");
}
if (input.clear_admin_reply === "1") {
sets.push("admin_reply = NULL");
changes.push("admin reply");
}
const clearAttachments = input.clear_attachments === "1";
const attachments = clearAttachments ? attachmentsFor(id) : [];
if (clearAttachments && attachments.length) {
changes.push("attachments");
}
if (!sets.length) {
if (!clearAttachments || !attachments.length) {
throw new Error("Choose at least one feedback data field to clean.");
} }
} else {
next = current.replace(section, `${section}\n\n${bullet}`);
} }
fs.writeFileSync(todoPath, next); if (!sets.length && clearAttachments && attachments.length) {
sets.push("updated_at = updated_at");
}
if (!changes.length) {
throw new Error("Choose at least one feedback data field to clean.");
}
const now = Date.now(); const now = Date.now();
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
"UPDATE feedback_entries SET linked_todo = ?, updated_at = ?, last_activity_at = ? WHERE id = ?" `UPDATE feedback_entries SET ${sets.join(", ")}, updated_at = ?, last_activity_at = ? WHERE id = ?`
).run(text, now, now, id); ).run(now, now, id);
if (clearAttachments) {
db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id);
}
db.prepare( db.prepare(
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)" "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); ).run(id, actor?.id || null, `Cleaned sensitive feedback data: ${changes.join(", ")}.`, now);
})(); })();
return text; if (input.clear_screenshot === "1" && current.screenshot?.path && typeof options.deleteScreenshot === "function") {
options.deleteScreenshot(current.screenshot.path);
}
if (clearAttachments && typeof options.deleteAttachment === "function") {
attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path));
}
return getFeedbackForAdmin(id);
}
function getFeedbackAttachment(feedbackId, attachmentId, userId, isAdmin = false) {
const row = db.prepare("SELECT id, submitter_id, deleted_at, status FROM feedback_entries WHERE id = ?").get(feedbackId);
if (!row || row.deleted_at || row.status === "deleted") {
return null;
}
if (!isAdmin && row.submitter_id !== userId) {
return null;
}
return db.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? AND id = ?").get(feedbackId, attachmentId) || null;
} }
function markFeedbackViewed(userId) { function markFeedbackViewed(userId) {
@ -407,15 +585,17 @@ function normalizeFeedbackInput(input = {}) {
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other"; const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other";
const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor"; const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor";
const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page"; const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page";
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
const pageTitle = cleanText(input.page_title, 240);
return { return {
summary, summary,
category, category,
severity, severity,
scope_type: scopeType, scope_type: scopeType,
scope_label: cleanText(input.scope_label, 240), scope_label: deriveScopeLabel(scopeType, targetMetadata, pageTitle, input.current_url),
target_metadata: sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata), target_metadata: targetMetadata,
current_url: cleanUrl(input.current_url), current_url: cleanUrl(input.current_url),
page_title: cleanText(input.page_title, 240), page_title: pageTitle,
description, description,
steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000), steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000),
expected_behavior: cleanText(input.expected_behavior, 4000), expected_behavior: cleanText(input.expected_behavior, 4000),
@ -424,6 +604,102 @@ function normalizeFeedbackInput(input = {}) {
}; };
} }
function deriveScopeLabel(scopeType, metadata, pageTitle, currentUrl) {
const pageLabel = pageTitle || pagePathLabel(currentUrl) || "this page";
const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector;
if (scopeType === "element") {
return cleanText(elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`, 240);
}
if (scopeType === "feature") return cleanText(`Feature/page: ${pageLabel}`, 240);
if (scopeType === "plugin") {
const plugin = pluginLabelFromUrl(currentUrl);
return cleanText(plugin ? `Plugin: ${plugin}` : `Plugin-related feedback on ${pageLabel}`, 240);
}
if (scopeType === "system") return cleanText(`System area: ${pageLabel}`, 240);
if (scopeType === "other") return cleanText(`Other feedback on ${pageLabel}`, 240);
return cleanText(`Whole page: ${pageLabel}`, 240);
}
function pluginLabelFromUrl(value) {
const raw = cleanText(value, 1000);
try {
const url = new URL(raw, "http://localhost");
const match = url.pathname.match(/\/plugins\/([^/]+)/);
return match ? match[1].replace(/[_-]+/g, " ") : "";
} catch {
return "";
}
}
function pagePathLabel(value) {
const raw = cleanText(value, 1000);
try {
const url = new URL(raw, "http://localhost");
return url.pathname === "/" ? "Home" : url.pathname.replace(/^\/+/, "").replace(/[/_-]+/g, " ");
} catch {
return "";
}
}
function pagePathKey(value) {
const raw = cleanText(value, 1000);
try {
const url = new URL(raw, "http://localhost");
return url.pathname.replace(/\/+$/, "") || "/";
} catch {
return "";
}
}
function tokenSet(value) {
return new Set(
cleanText(value, 500)
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length >= 3)
);
}
function jaccardScore(a, b) {
if (!a.size || !b.size) return 0;
let intersection = 0;
for (const token of a) {
if (b.has(token)) intersection += 1;
}
return intersection / new Set([...a, ...b]).size;
}
function supportSummary(ids, userId) {
const cleanIds = [...new Set((ids || []).filter(Boolean))];
const counts = new Map();
const mine = new Set();
if (!cleanIds.length) {
return { counts, mine };
}
const placeholders = cleanIds.map(() => "?").join(",");
db.prepare(
`SELECT feedback_id, COUNT(*) AS count FROM feedback_support WHERE feedback_id IN (${placeholders}) GROUP BY feedback_id`
)
.all(...cleanIds)
.forEach((row) => counts.set(row.feedback_id, row.count));
if (userId) {
db.prepare(
`SELECT feedback_id FROM feedback_support WHERE user_id = ? AND feedback_id IN (${placeholders})`
)
.all(userId, ...cleanIds)
.forEach((row) => mine.add(row.feedback_id));
}
return { counts, mine };
}
function parseDateBoundary(value, edge) {
const raw = cleanText(value, 40);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const date = new Date(`${raw}T${edge === "end" ? "23:59:59.999" : "00:00:00.000"}`);
const time = date.getTime();
return Number.isFinite(time) ? time : null;
}
function enforceRateLimit(userId) { function enforceRateLimit(userId) {
const cutoff = Date.now() - RATE_LIMIT.windowMs; const cutoff = Date.now() - RATE_LIMIT.windowMs;
const count = db const count = db
@ -441,6 +717,14 @@ function hydrateFeedback(row, { admin }) {
...decorateLabels(row), ...decorateLabels(row),
target_metadata: parseJson(row.target_metadata_json, {}), target_metadata: parseJson(row.target_metadata_json, {}),
diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {}, diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {},
screenshot: row.screenshot_path
? {
path: row.screenshot_path,
mime: row.screenshot_mime || "image/png",
size: row.screenshot_size || 0
}
: null,
attachments: attachmentsFor(row.id),
comments: commentsFor(row.id, admin), comments: commentsFor(row.id, admin),
history: statusHistoryFor(row.id) history: statusHistoryFor(row.id)
}; };
@ -453,6 +737,21 @@ function hydrateFeedback(row, { admin }) {
return parsed; return parsed;
} }
function attachmentsFor(feedbackId) {
return db
.prepare("SELECT * FROM feedback_attachments WHERE feedback_id = ? ORDER BY created_at ASC")
.all(feedbackId)
.map((row) => ({
id: row.id,
storage_path: row.storage_path,
original_name: row.original_name || "attachment",
mime: row.mime,
size: row.size,
kind: row.kind,
created_at: row.created_at
}));
}
function commentsFor(feedbackId, admin) { function commentsFor(feedbackId, admin) {
return db return db
.prepare( .prepare(
@ -502,11 +801,6 @@ function decorateLabels(row) {
}; };
} }
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) { function sanitizeTargetMetadata(value) {
return { return {
selector: cleanText(value.selector, 500), selector: cleanText(value.selector, 500),
@ -527,10 +821,57 @@ function sanitizeDiagnostics(value) {
return { return {
user_agent: cleanText(value.user_agent, 500), user_agent: cleanText(value.user_agent, 500),
viewport: cleanText(value.viewport, 80), viewport: cleanText(value.viewport, 80),
language: cleanText(value.language, 80) language: cleanText(value.language, 80),
dom_snapshot: cleanText(value.dom_snapshot, 6000),
screenshot_mode: cleanText(value.screenshot_mode, 40),
screenshot_source: cleanText(value.screenshot_source, 40)
}; };
} }
function normalizeScreenshot(value = {}) {
const pathValue = cleanText(value.path, 500);
const mime = cleanText(value.mime, 80);
const size = Number(value.size || 0);
if (!pathValue) {
return { path: null, mime: null, size: null };
}
if (!/^feedback\/screenshots\/[a-zA-Z0-9_.-]+$/.test(pathValue)) {
throw new Error("Invalid screenshot storage path.");
}
if (!["image/png", "image/jpeg", "image/webp"].includes(mime)) {
throw new Error("Unsupported screenshot type.");
}
if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) {
throw new Error("Screenshot file size is invalid.");
}
return { path: pathValue, mime, size };
}
function normalizeAttachments(values = []) {
const entries = Array.isArray(values) ? values : [];
return entries.map((value) => {
const pathValue = cleanText(value.path, 500);
const mime = cleanText(value.mime, 80);
const size = Number(value.size || 0);
if (!/^feedback\/attachments\/[a-zA-Z0-9_.-]+$/.test(pathValue)) {
throw new Error("Invalid attachment storage path.");
}
if (!["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(mime)) {
throw new Error("Unsupported attachment type.");
}
if (!Number.isFinite(size) || size <= 0 || size > 8 * 1024 * 1024) {
throw new Error("Attachment file size is invalid.");
}
return {
path: pathValue,
mime,
size,
original_name: cleanText(value.original_name, 240) || "attachment",
kind: "attachment"
};
});
}
function sanitizeJsonObject(value, sanitizer) { function sanitizeJsonObject(value, sanitizer) {
const object = typeof value === "object" && value && !Array.isArray(value) ? value : {}; const object = typeof value === "object" && value && !Array.isArray(value) ? value : {};
return removeEmptyFields(sanitizer(object)); return removeEmptyFields(sanitizer(object));
@ -568,6 +909,7 @@ function cleanUrl(value) {
function commentKindLabel(kind) { function commentKindLabel(kind) {
return { return {
submitter_comment: "Submitter comment", submitter_comment: "Submitter comment",
public_comment: "Community comment",
admin_reply: "Admin reply", admin_reply: "Admin reply",
work_note: "Private work note" work_note: "Private work note"
}[kind] || kind; }[kind] || kind;
@ -591,15 +933,20 @@ module.exports = {
FEEDBACK_STATUSES, FEEDBACK_STATUSES,
USER_VISIBLE_STATUSES, USER_VISIBLE_STATUSES,
adminUpdateFeedback, adminUpdateFeedback,
convertFeedbackToTodo, cleanupFeedback,
deleteFeedback,
createFeedback, createFeedback,
findSimilarFeedback,
feedbackOptions, feedbackOptions,
getFeedbackForAdmin, getFeedbackForAdmin,
getFeedbackAttachment,
getFeedbackForSubmitter, getFeedbackForSubmitter,
getFeedbackForViewer,
listFeedbackForAdmin, listFeedbackForAdmin,
listMyFeedback, listMyFeedback,
listPublicFeedback, listPublicFeedback,
markFeedbackViewed, markFeedbackViewed,
notificationSummary, notificationSummary,
supportFeedback,
addSubmitterComment addSubmitterComment
}; };

View File

@ -951,6 +951,17 @@
const contextMenu = document.querySelector("[data-lumi-context-menu]"); const contextMenu = document.querySelector("[data-lumi-context-menu]");
let contextTarget = null; let contextTarget = null;
let highlightedFeedbackTarget = null; let highlightedFeedbackTarget = null;
let contextClipboardText = "";
let contextTooltipTimer = null;
let contextTooltip = null;
let activeFeedbackTarget = null;
let activeFeedbackMetadata = {};
let attachedScreenshotBlob = null;
let attachedScreenshotUrl = "";
let attachedScreenshotSource = "";
let attachedScreenshotName = "feedback-screenshot.png";
let feedbackSimilarTimer = null;
let feedbackSimilarController = null;
const parseJson = (value, fallback = {}) => { const parseJson = (value, fallback = {}) => {
try { try {
@ -980,6 +991,35 @@
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page"); setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page");
} }
const scopeLabelForValue = (value) => {
const option = (feedbackOptions.scopes || []).find((entry) => entry.value === value);
return option?.label || value;
};
const screenshotShortcutHint = () => {
const platform = `${navigator.userAgentData?.platform || navigator.platform || ""}`.toLowerCase();
if (platform.includes("mac")) {
return "Press Shift + Command + 4 to take a selected-area screenshot";
}
if (platform.includes("linux")) {
return "Press Print Screen or Shift + Print Screen to take a screenshot";
}
if (platform.includes("iphone") || platform.includes("ipad")) {
return "Use your device screenshot buttons, then choose the saved image";
}
if (platform.includes("android")) {
return "Use your device screenshot buttons, then choose the saved image";
}
return "Press Win + Shift + S to take a snippet screenshot";
};
if (feedbackForm) {
const keybindHint = feedbackForm.querySelector("[data-feedback-screenshot-keybind]");
if (keybindHint) {
keybindHint.textContent = screenshotShortcutHint();
}
}
const sensitiveSelector = [ const sensitiveSelector = [
"input[type='password']", "input[type='password']",
"input[type='hidden']", "input[type='hidden']",
@ -1049,6 +1089,28 @@
return safeText(h1?.innerText || document.title, 180); return safeText(h1?.innerText || document.title, 180);
}; };
const generatedScopeLabel = (scope, metadata = {}) => {
const pageLabel = currentPageLabel() || "this page";
const elementLabel = metadata.label || metadata.text || metadata.aria_label || metadata.title || metadata.selector;
if (scope === "element") return elementLabel ? `Clicked element: ${elementLabel}` : `Clicked element on ${pageLabel}`;
if (scope === "feature") return `Feature/page: ${pageLabel}`;
if (scope === "plugin") {
const pluginMatch = window.location.pathname.match(/\/plugins\/([^/]+)/);
return pluginMatch ? `Plugin: ${pluginMatch[1].replaceAll("_", " ")}` : `Plugin-related feedback on ${pageLabel}`;
}
if (scope === "system") return `System area: ${pageLabel}`;
if (scope === "other") return `Other feedback on ${pageLabel}`;
return `Whole page: ${pageLabel}`;
};
const updateGeneratedScopeLabel = () => {
if (!feedbackForm) return;
const scopeSelect = feedbackForm.querySelector("[data-feedback-scope-type]");
const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]");
if (!scopeSelect || !scopeLabel) return;
scopeLabel.value = generatedScopeLabel(scopeSelect.value, activeFeedbackMetadata);
};
const clearFeedbackHighlight = () => { const clearFeedbackHighlight = () => {
highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight"); highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight");
highlightedFeedbackTarget = null; highlightedFeedbackTarget = null;
@ -1059,23 +1121,24 @@
hideContextMenu(); hideContextMenu();
clearFeedbackHighlight(); clearFeedbackHighlight();
feedbackForm.reset(); feedbackForm.reset();
clearAttachedScreenshot();
setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug"); setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug");
setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor"); setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor");
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope); setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope);
const metadata = target ? feedbackTargetMetadata(target) : {}; const metadata = target ? feedbackTargetMetadata(target) : {};
activeFeedbackTarget = target;
activeFeedbackMetadata = metadata;
const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]"); const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]");
const urlInput = feedbackForm.querySelector("[data-feedback-current-url]"); const urlInput = feedbackForm.querySelector("[data-feedback-current-url]");
const titleInput = feedbackForm.querySelector("[data-feedback-page-title]"); const titleInput = feedbackForm.querySelector("[data-feedback-page-title]");
const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]"); const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]");
const scopeLabel = feedbackForm.querySelector("[data-feedback-scope-label]");
const status = feedbackForm.querySelector("[data-feedback-status]"); const status = feedbackForm.querySelector("[data-feedback-status]");
if (targetInput) targetInput.value = JSON.stringify(metadata); if (targetInput) targetInput.value = JSON.stringify(metadata);
if (urlInput) urlInput.value = window.location.href; if (urlInput) urlInput.value = window.location.href;
if (titleInput) titleInput.value = document.title; if (titleInput) titleInput.value = document.title;
if (diagnosticsInput) diagnosticsInput.value = "{}"; if (diagnosticsInput) diagnosticsInput.value = "{}";
if (scopeLabel) scopeLabel.value = scope === "element" updateGeneratedScopeLabel();
? (metadata.label || metadata.text || metadata.selector || "Clicked element") clearSimilarFeedback();
: currentPageLabel();
if (status) status.textContent = ""; if (status) status.textContent = "";
if (target && scope === "element") { if (target && scope === "element") {
highlightedFeedbackTarget = target; highlightedFeedbackTarget = target;
@ -1091,6 +1154,8 @@
feedbackModal.classList.remove("is-open"); feedbackModal.classList.remove("is-open");
feedbackModal.setAttribute("aria-hidden", "true"); feedbackModal.setAttribute("aria-hidden", "true");
clearFeedbackHighlight(); clearFeedbackHighlight();
activeFeedbackTarget = null;
activeFeedbackMetadata = {};
}; };
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
@ -1106,28 +1171,174 @@
} }
}); });
feedbackForm?.querySelector("[data-feedback-scope-type]")?.addEventListener("change", updateGeneratedScopeLabel);
["input", "change"].forEach((eventName) => {
feedbackForm?.addEventListener(eventName, (event) => {
if (!event.target.matches?.("input[name='summary'], textarea[name='description'], select[name='category'], select[name='scope_type']")) return;
scheduleSimilarFeedbackLookup();
});
});
feedbackForm?.addEventListener("click", async (event) => {
const supportButton = event.target.closest("[data-feedback-support-similar]");
if (!supportButton) return;
event.preventDefault();
supportButton.disabled = true;
try {
const response = await fetch(`/api/feedback/${encodeURIComponent(supportButton.dataset.feedbackSupportSimilar)}/support`, {
method: "POST",
headers: { Accept: "application/json" }
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Could not mark this as affecting you.");
supportButton.textContent = `Affects me too (${result.support_count})`;
} catch (error) {
supportButton.disabled = false;
supportButton.textContent = error.message || "Could not mark";
}
});
feedbackForm?.querySelector("[data-feedback-screenshot-file]")?.addEventListener("change", (event) => {
const file = event.target.files?.[0];
if (file) {
setAttachedScreenshot(file, "upload", file.name || "feedback-screenshot.png");
}
event.target.value = "";
});
feedbackForm?.querySelector("[data-feedback-paste-screenshot]")?.addEventListener("click", async () => {
await pasteFeedbackScreenshot();
});
feedbackModal?.addEventListener("paste", (event) => {
const file = imageFileFromClipboardData(event.clipboardData);
if (!file) return;
event.preventDefault();
setAttachedScreenshot(file, "paste", file.name || "feedback-screenshot.png");
});
feedbackForm?.querySelector("[data-feedback-remove-screenshot]")?.addEventListener("click", clearAttachedScreenshot);
function clearSimilarFeedback() {
feedbackSimilarController?.abort?.();
feedbackSimilarController = null;
window.clearTimeout(feedbackSimilarTimer);
const box = feedbackForm?.querySelector("[data-feedback-similar]");
const list = feedbackForm?.querySelector("[data-feedback-similar-list]");
if (box) box.hidden = true;
if (list) list.replaceChildren();
}
function scheduleSimilarFeedbackLookup() {
window.clearTimeout(feedbackSimilarTimer);
feedbackSimilarTimer = window.setTimeout(loadSimilarFeedback, 450);
}
async function loadSimilarFeedback() {
if (!feedbackForm) return;
const summary = feedbackForm.querySelector("input[name='summary']")?.value || "";
const description = feedbackForm.querySelector("textarea[name='description']")?.value || "";
if (summary.trim().length < 6 && description.trim().length < 12) {
clearSimilarFeedback();
return;
}
feedbackSimilarController?.abort?.();
feedbackSimilarController = new AbortController();
const params = new URLSearchParams({
summary,
description,
category: feedbackForm.querySelector("select[name='category']")?.value || "",
scope_type: feedbackForm.querySelector("select[name='scope_type']")?.value || "",
current_url: window.location.href,
limit: "4"
});
try {
const response = await fetch(`/api/feedback/similar?${params.toString()}`, {
headers: { Accept: "application/json" },
signal: feedbackSimilarController.signal
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Could not load similar feedback.");
renderSimilarFeedback(result.matches || []);
} catch (error) {
if (error.name !== "AbortError") {
renderSimilarFeedback([]);
}
}
}
function renderSimilarFeedback(matches) {
const box = feedbackForm?.querySelector("[data-feedback-similar]");
const list = feedbackForm?.querySelector("[data-feedback-similar-list]");
if (!box || !list) return;
list.replaceChildren();
if (!matches.length) {
box.hidden = true;
return;
}
matches.forEach((item) => {
const row = document.createElement("article");
row.className = "feedback-similar-item";
const title = document.createElement("strong");
title.textContent = item.summary;
const meta = document.createElement("span");
meta.className = "hint";
meta.textContent = `${item.category_label} · ${item.scope_label_display} · ${item.status_label} · ${item.support_count || 0} also affected`;
const actions = document.createElement("div");
actions.className = "button-group";
const open = document.createElement("a");
open.className = "button subtle";
open.href = `/feedback?id=${encodeURIComponent(item.id)}`;
open.textContent = "Open";
const support = document.createElement("button");
support.type = "button";
support.className = "button subtle";
support.dataset.feedbackSupportSimilar = item.id;
support.disabled = Boolean(item.supported_by_me);
support.textContent = item.supported_by_me ? "Affects me too" : "This affects me too";
actions.append(open, support);
row.append(title, meta, actions);
list.append(row);
});
box.hidden = false;
}
feedbackForm?.addEventListener("submit", async (event) => { feedbackForm?.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const submitter = feedbackForm.querySelector("button[type='submit']"); const submitter = feedbackForm.querySelector("button[type='submit']");
const status = feedbackForm.querySelector("[data-feedback-status]"); const status = feedbackForm.querySelector("[data-feedback-status]");
const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked; const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked;
const includeDom = feedbackForm.querySelector("[data-feedback-include-dom]")?.checked;
const formData = new FormData(feedbackForm); const formData = new FormData(feedbackForm);
const payload = Object.fromEntries(formData.entries()); formData.set("target_metadata", JSON.stringify(parseJson(formData.get("target_metadata"), {})));
payload.target_metadata = parseJson(payload.target_metadata, {}); formData.set("diagnostics", JSON.stringify({
payload.diagnostics = includeBrowser ...(includeBrowser
? { ? {
user_agent: navigator.userAgent, user_agent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`, viewport: `${window.innerWidth}x${window.innerHeight}`,
language: navigator.language || "" language: navigator.language || ""
} }
: {}; : {}),
...(includeDom
? { dom_snapshot: sanitizedDomSnapshot(activeFeedbackTarget || document.querySelector("main") || document.body) }
: {}),
...(attachedScreenshotBlob
? {
screenshot_mode: "user_attached",
screenshot_source: attachedScreenshotSource
}
: {})
}));
if (attachedScreenshotBlob) {
formData.set("screenshot", attachedScreenshotBlob, attachedScreenshotName);
}
submitter.disabled = true; submitter.disabled = true;
if (status) status.textContent = "Submitting feedback..."; if (status) status.textContent = "Submitting feedback...";
try { try {
const response = await fetch("/api/feedback", { const response = await fetch("/api/feedback", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { Accept: "application/json" },
body: JSON.stringify(payload) body: formData
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok) throw new Error(result.error || "Feedback could not be submitted."); if (!response.ok) throw new Error(result.error || "Feedback could not be submitted.");
@ -1142,35 +1353,221 @@
} }
}); });
const isEditableTarget = (target) => { function sanitizedDomSnapshot(root) {
const lines = [];
const maxNodes = 80;
const maxDepth = 4;
const skipTags = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"]);
const walk = (node, depth = 0) => {
if (!node || lines.length >= maxNodes || depth > maxDepth) return;
if (node.nodeType !== 1 || skipTags.has(node.tagName)) return;
const indent = " ".repeat(depth);
const tag = node.tagName.toLowerCase();
const attrs = [];
if (node.id) attrs.push(`#${node.id.slice(0, 60)}`);
if (node.classList?.length) attrs.push(`.${Array.from(node.classList).slice(0, 3).join(".").slice(0, 80)}`);
const role = node.getAttribute?.("role");
const label = node.getAttribute?.("aria-label") || node.getAttribute?.("title");
if (role) attrs.push(`role=${role.slice(0, 40)}`);
if (label) attrs.push(`label="${safeText(label, 80)}"`);
const sensitive = node.matches?.(sensitiveSelector) || ["INPUT", "TEXTAREA", "SELECT"].includes(node.tagName);
const text = sensitive ? "" : safeElementText(node);
lines.push(`${indent}<${tag}${attrs.length ? ` ${attrs.join(" ")}` : ""}>${text ? ` ${safeText(text, 120)}` : ""}`);
Array.from(node.children || []).slice(0, 10).forEach((child) => walk(child, depth + 1));
};
walk(root);
return lines.join("\n").slice(0, 6000);
}
function clearAttachedScreenshot() {
if (attachedScreenshotUrl) {
URL.revokeObjectURL(attachedScreenshotUrl);
}
attachedScreenshotBlob = null;
attachedScreenshotUrl = "";
attachedScreenshotSource = "";
attachedScreenshotName = "feedback-screenshot.png";
const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]");
const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]");
const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]");
const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]");
const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (preview) preview.hidden = true;
if (image) image.removeAttribute("src");
if (meta) meta.textContent = "";
if (removeButton) removeButton.hidden = true;
if (screenshotStatus) screenshotStatus.textContent = "";
}
async function pasteFeedbackScreenshot() {
const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (screenshotStatus) {
screenshotStatus.textContent = "Looking for an image in the clipboard...";
}
try {
if (!navigator.clipboard?.read || !window.isSecureContext) {
throw new Error("Clipboard image read is not available.");
}
const items = await navigator.clipboard.read();
for (const item of items) {
const type = item.types.find((entry) => entry.startsWith("image/"));
if (type) {
const blob = await item.getType(type);
setAttachedScreenshot(blob, "paste", `feedback-screenshot${screenshotExtension(type)}`);
return;
}
}
throw new Error("No image was found in the clipboard.");
} catch (error) {
if (screenshotStatus) {
screenshotStatus.textContent = `${error.message || "Clipboard image could not be read."} You can also copy a screenshot and press Ctrl+V while this dialog is open.`;
}
}
}
function imageFileFromClipboardData(data) {
const files = Array.from(data?.files || []);
const file = files.find((entry) => entry.type?.startsWith("image/"));
if (file) return file;
const items = Array.from(data?.items || []);
const item = items.find((entry) => entry.type?.startsWith("image/"));
return item?.getAsFile?.() || null;
}
function screenshotExtension(mime) {
if (mime === "image/jpeg") return ".jpg";
if (mime === "image/webp") return ".webp";
return ".png";
}
function screenshotLabel(source) {
return source === "paste" ? "Pasted screenshot" : "Uploaded screenshot";
}
function setAttachedScreenshot(blob, source, name = "feedback-screenshot.png") {
const screenshotStatus = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (!["image/png", "image/jpeg", "image/webp"].includes(blob.type)) {
if (screenshotStatus) screenshotStatus.textContent = "Only PNG, JPEG, or WebP screenshots can be attached.";
return;
}
if (blob.size > 8 * 1024 * 1024) {
if (screenshotStatus) screenshotStatus.textContent = "Screenshot must be 8 MB or smaller.";
return;
}
clearAttachedScreenshot();
attachedScreenshotBlob = blob;
attachedScreenshotUrl = URL.createObjectURL(blob);
attachedScreenshotSource = source;
attachedScreenshotName = name || `feedback-screenshot${screenshotExtension(blob.type)}`;
const preview = feedbackForm?.querySelector("[data-feedback-screenshot-preview]");
const image = feedbackForm?.querySelector("[data-feedback-screenshot-image]");
const meta = feedbackForm?.querySelector("[data-feedback-screenshot-meta]");
const removeButton = feedbackForm?.querySelector("[data-feedback-remove-screenshot]");
const status = feedbackForm?.querySelector("[data-feedback-screenshot-status]");
if (image && attachedScreenshotUrl) {
image.src = attachedScreenshotUrl;
}
if (meta) {
const sizeKb = Math.max(1, Math.round(blob.size / 1024));
meta.textContent = `${screenshotLabel(source)} · ${blob.type.replace("image/", "").toUpperCase()} · ${sizeKb} KB`;
}
if (preview) preview.hidden = !attachedScreenshotUrl;
if (removeButton) removeButton.hidden = false;
if (status) status.textContent = "Screenshot attached. Review the preview and remove it if it contains sensitive information.";
}
const editableElementForTarget = (target) => {
const element = target?.closest?.("input, textarea, [contenteditable='true']"); const element = target?.closest?.("input, textarea, [contenteditable='true']");
return element && !element.matches("[readonly], [disabled]"); if (!element || element.matches("[readonly], [disabled]")) return null;
const inputType = String(element.getAttribute("type") || "").toLowerCase();
if (["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(inputType)) {
return null;
}
return element;
}; };
const updateContextMenuItems = (target) => { const selectedTextForTarget = (target) => {
const editable = editableElementForTarget(target);
if (editable && typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") {
return editable.value.slice(editable.selectionStart, editable.selectionEnd);
}
return window.getSelection?.().toString() || "";
};
const hasInputSelection = (target) => {
const editable = editableElementForTarget(target);
if (!editable) return false;
if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") {
return editable.selectionEnd > editable.selectionStart;
}
const selection = window.getSelection?.();
return Boolean(selection && !selection.isCollapsed && editable.contains(selection.anchorNode));
};
const contextImageForTarget = (target) => target?.closest?.("img") || null;
const readClipboardText = async () => {
try {
if (!navigator.clipboard?.readText || !window.isSecureContext) return "";
return await navigator.clipboard.readText();
} catch {
return "";
}
};
const setContextActionEnabled = (action, enabled) => {
const button = contextMenu?.querySelector(`[data-context-action="${action}"]`);
if (!button) return;
button.setAttribute("aria-disabled", enabled ? "false" : "true");
button.classList.toggle("is-disabled", !enabled);
button.tabIndex = enabled ? 0 : -1;
};
const enabledContextButtons = () =>
Array.from(contextMenu?.querySelectorAll("[data-context-action]:not(.is-disabled)") || []);
const focusContextButton = (offset) => {
const buttons = enabledContextButtons();
if (!buttons.length) return;
const current = buttons.indexOf(document.activeElement);
const next = current === -1 ? 0 : (current + offset + buttons.length) % buttons.length;
buttons[next].focus();
};
const updateContextMenuItems = async (target) => {
if (!contextMenu) return; if (!contextMenu) return;
const editable = Boolean(isEditableTarget(target)); const active = document.activeElement;
contextMenu.querySelector("[data-context-action='cut']")?.toggleAttribute("hidden", !editable); const pasteTarget = editableElementForTarget(active);
contextMenu.querySelector("[data-context-action='paste']")?.toggleAttribute("hidden", !editable); const selectedText = selectedTextForTarget(target).trim();
contextMenu.querySelector("[data-context-action='copy']")?.toggleAttribute("hidden", false); const image = contextImageForTarget(target);
contextClipboardText = pasteTarget ? await readClipboardText() : "";
setContextActionEnabled("back", window.history.length > 1);
setContextActionEnabled("forward", Boolean(window.navigation?.canGoForward));
setContextActionEnabled("copy", Boolean(selectedText || image));
setContextActionEnabled("cut", hasInputSelection(target));
setContextActionEnabled("paste", Boolean(pasteTarget && contextClipboardText.length > 0));
setContextActionEnabled("link", true);
setContextActionEnabled("reload", true);
setContextActionEnabled("feedback", true);
}; };
const showContextMenu = (event) => { const showContextMenu = async (event) => {
if (!contextMenu || event.shiftKey) return; if (!contextMenu || event.shiftKey) return;
event.preventDefault(); event.preventDefault();
contextTarget = event.target; contextTarget = event.target;
updateContextMenuItems(contextTarget);
contextMenu.hidden = false; contextMenu.hidden = false;
await updateContextMenuItems(contextTarget);
const width = contextMenu.offsetWidth || 180; const width = contextMenu.offsetWidth || 180;
const height = contextMenu.offsetHeight || 260; const height = contextMenu.offsetHeight || 260;
contextMenu.style.left = `${Math.min(event.clientX, window.innerWidth - width - 8)}px`; 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.style.top = `${Math.min(event.clientY, window.innerHeight - height - 8)}px`;
contextMenu.querySelector("button:not([hidden])")?.focus(); contextMenu.querySelector("button:not(.is-disabled)")?.focus();
}; };
function hideContextMenu() { function hideContextMenu() {
if (!contextMenu) return; if (!contextMenu) return;
contextMenu.hidden = true; contextMenu.hidden = true;
hideContextTooltip();
} }
const linkForTarget = (target) => { const linkForTarget = (target) => {
@ -1180,19 +1577,49 @@
return url.toString(); return url.toString();
}; };
const runContextAction = async (action) => { const copyImage = async (image) => {
const editable = isEditableTarget(contextTarget); if (!image) return false;
try {
if (!navigator.clipboard?.write || !window.ClipboardItem || !window.isSecureContext) return false;
const response = await fetch(image.currentSrc || image.src);
const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]);
return true;
} catch {
return false;
}
};
const insertTextIntoEditable = (editable, text) => {
if (!editable || !text) return;
editable.focus();
if (typeof editable.selectionStart === "number" && typeof editable.selectionEnd === "number") {
const start = editable.selectionStart;
const end = editable.selectionEnd;
editable.setRangeText(text, start, end, "end");
editable.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
document.execCommand("insertText", false, text);
};
const runContextAction = async (action, button) => {
if (button?.classList.contains("is-disabled") || button?.getAttribute("aria-disabled") === "true") return;
const editable = editableElementForTarget(contextTarget) || editableElementForTarget(document.activeElement);
try { try {
if (action === "back") window.history.back(); if (action === "back") window.history.back();
if (action === "forward") window.history.forward(); if (action === "forward") {
if (window.navigation?.canGoForward) window.history.forward();
}
if (action === "copy") { if (action === "copy") {
const selection = window.getSelection?.().toString(); const selectedText = selectedTextForTarget(contextTarget);
await copyText(selection || linkForTarget(contextTarget)); if (selectedText) await copyText(selectedText);
else await copyImage(contextImageForTarget(contextTarget));
} }
if (action === "cut" && editable) document.execCommand("cut"); if (action === "cut" && editable) document.execCommand("cut");
if (action === "paste" && editable) { if (action === "paste" && editable) {
const text = await navigator.clipboard?.readText?.(); const text = contextClipboardText || await readClipboardText();
if (text !== undefined) document.execCommand("insertText", false, text); insertTextIntoEditable(editable, text);
} }
if (action === "link") await copyText(linkForTarget(contextTarget)); if (action === "link") await copyText(linkForTarget(contextTarget));
if (action === "reload") window.location.reload(true); if (action === "reload") window.location.reload(true);
@ -1212,13 +1639,91 @@
return; return;
} }
event.preventDefault(); event.preventDefault();
runContextAction(button.dataset.contextAction); runContextAction(button.dataset.contextAction, button);
});
contextMenu?.addEventListener("pointerover", (event) => {
const button = event.target.closest("[data-context-action]");
if (!button || !contextMenu.contains(button)) return;
window.clearTimeout(contextTooltipTimer);
contextTooltipTimer = window.setTimeout(() => showContextTooltip(button), 1000);
});
contextMenu?.addEventListener("pointerout", (event) => {
const button = event.target.closest("[data-context-action]");
if (!button) return;
const next = event.relatedTarget;
if (next && (button.contains(next) || contextTooltip?.contains(next))) return;
window.clearTimeout(contextTooltipTimer);
hideContextTooltip();
}); });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (!contextMenu?.hidden && contextMenu?.contains(document.activeElement)) {
if (event.key === "ArrowDown") {
event.preventDefault();
focusContextButton(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
focusContextButton(-1);
return;
}
if (event.key === "Home") {
event.preventDefault();
enabledContextButtons()[0]?.focus();
return;
}
if (event.key === "End") {
event.preventDefault();
enabledContextButtons().at(-1)?.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
const button = document.activeElement?.closest?.("[data-context-action]");
if (button) {
event.preventDefault();
runContextAction(button.dataset.contextAction, button);
return;
}
}
if (event.key === "Tab") {
event.preventDefault();
focusContextButton(event.shiftKey ? -1 : 1);
return;
}
}
if (event.key === "Escape") { if (event.key === "Escape") {
hideContextMenu(); hideContextMenu();
closeFeedbackModal(); closeFeedbackModal();
} }
}); });
window.addEventListener("blur", hideContextMenu); window.addEventListener("blur", hideContextMenu);
function showContextTooltip(button) {
const text = button?.dataset.contextTooltip || "";
if (!text) return;
hideContextTooltip();
contextTooltip = document.createElement("div");
contextTooltip.className = "lumi-context-tooltip";
contextTooltip.textContent = text;
contextTooltip.setAttribute("role", "tooltip");
document.body.append(contextTooltip);
const buttonRect = button.getBoundingClientRect();
const tooltipRect = contextTooltip.getBoundingClientRect();
const padding = 8;
let left = buttonRect.right + padding;
let top = buttonRect.top + (buttonRect.height - tooltipRect.height) / 2;
if (left + tooltipRect.width > window.innerWidth - padding) {
left = buttonRect.left - tooltipRect.width - padding;
}
left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding));
top = Math.max(padding, Math.min(top, window.innerHeight - tooltipRect.height - padding));
contextTooltip.style.left = `${left}px`;
contextTooltip.style.top = `${top}px`;
}
function hideContextTooltip() {
window.clearTimeout(contextTooltipTimer);
contextTooltip?.remove();
contextTooltip = null;
}
})(); })();

View File

@ -351,6 +351,30 @@ button:disabled {
width: 100%; width: 100%;
} }
.button-group {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--lumi-space-2);
align-items: center;
}
.form-grid .field.button-group,
.field.button-group {
flex-direction: row;
}
.button-group.centered {
justify-content: center;
}
.button-group .button,
.button-group button.button,
.button-group input[type="submit"].button,
.button-group .lumi-state-btn {
flex: 0 0 auto;
}
.lumi-state-btn-spinner { .lumi-state-btn-spinner {
width: 1em; width: 1em;
height: 1em; height: 1em;
@ -1107,6 +1131,16 @@ input[type="color"] {
box-shadow: var(--lumi-shadow-lg); box-shadow: var(--lumi-shadow-lg);
} }
.modal.feedback-modal {
width: min(50vw, 60rem);
min-width: min(92vw, 45rem);
max-height: 75vh;
}
.feedback-modal .modal-actions {
justify-content: center;
}
.modal-header h2, .modal-header h2,
.modal-header h3 { .modal-header h3 {
margin-bottom: 0; margin-bottom: 0;
@ -1246,6 +1280,19 @@ details > summary {
padding: var(--lumi-space-3); padding: var(--lumi-space-3);
} }
.feedback-modal-backdrop {
align-items: stretch;
padding: var(--lumi-space-2);
}
.modal.feedback-modal {
width: 100%;
min-width: 0;
max-height: calc(100dvh - (var(--lumi-space-2) * 2));
min-height: calc(100dvh - (var(--lumi-space-2) * 2));
padding: var(--lumi-space-3);
}
.hero { .hero {
padding: var(--lumi-space-4); padding: var(--lumi-space-4);
} }

View File

@ -1285,16 +1285,97 @@ body {
text-align: center; text-align: center;
} }
.feedback-modal {
max-width: 820px;
}
.feedback-submit-status { .feedback-submit-status {
min-height: 20px; min-height: 20px;
color: var(--ink-soft); color: var(--ink-soft);
font-weight: 700; font-weight: 700;
} }
.feedback-screenshot-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
}
.feedback-screenshot-actions label.button {
margin: 0;
cursor: pointer;
}
.feedback-screenshot-guide {
display: grid;
gap: 4px;
margin: 2px 0 10px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--sea) 35%, var(--border));
border-radius: 12px;
background: color-mix(in srgb, var(--sea) 9%, var(--surface-2));
}
.feedback-screenshot-guide p {
margin: 0;
max-width: none;
}
.feedback-screenshot-preview {
display: inline-flex;
gap: 10px;
align-items: center;
width: fit-content;
max-width: 100%;
margin-top: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
}
.feedback-screenshot-preview[hidden] {
display: none;
}
.feedback-screenshot-preview img {
width: auto;
max-width: min(260px, 42vw);
max-height: 160px;
border-radius: 8px;
object-fit: contain;
background: var(--surface-3);
}
.feedback-screenshot-preview img:not([src]) {
display: none;
}
.feedback-similar {
padding: 12px;
border: 1px solid color-mix(in srgb, var(--sun) 40%, var(--border));
border-radius: 12px;
background: color-mix(in srgb, var(--sun) 8%, var(--surface-2));
}
.feedback-similar-list {
display: grid;
gap: 8px;
}
.feedback-similar-item {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-2);
}
.feedback-attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.feedback-page-actions, .feedback-page-actions,
.feedback-badge-row, .feedback-badge-row,
.feedback-two-col, .feedback-two-col,
@ -1418,7 +1499,8 @@ body {
} }
.feedback-status-fixed, .feedback-status-fixed,
.feedback-status-solved { .feedback-status-solved,
.feedback-status-closed {
border-color: rgba(48, 170, 112, 0.45); border-color: rgba(48, 170, 112, 0.45);
color: var(--success); color: var(--success);
} }
@ -1517,6 +1599,32 @@ body {
background: var(--surface-2); background: var(--surface-2);
} }
.lumi-context-menu button.is-disabled {
color: var(--ink-soft);
cursor: not-allowed;
opacity: 0.48;
}
.lumi-context-menu button.is-disabled:hover,
.lumi-context-menu button.is-disabled:focus {
background: transparent;
}
.lumi-context-tooltip {
position: fixed;
z-index: 130;
max-width: min(280px, calc(100vw - 16px));
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-3);
color: var(--ink);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26);
font-size: 13px;
line-height: 1.35;
pointer-events: none;
}
.checkbox-inline { .checkbox-inline {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
@ -1786,6 +1894,33 @@ body .modal-backdrop.destructive-confirm-modal {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.feedback-modal .form-grid {
grid-template-columns: 1fr;
}
.feedback-modal .field.full {
grid-column: auto;
}
.feedback-screenshot-actions,
.feedback-modal .modal-actions {
align-items: stretch;
flex-direction: column;
}
.feedback-screenshot-actions .button,
.feedback-modal .modal-actions .button {
width: 100%;
}
.feedback-screenshot-preview {
width: 100%;
}
.feedback-screenshot-preview img {
max-width: 100%;
}
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -113,15 +113,21 @@ const {
const { const {
addSubmitterComment, addSubmitterComment,
adminUpdateFeedback, adminUpdateFeedback,
convertFeedbackToTodo, cleanupFeedback,
createFeedback, createFeedback,
deleteFeedback,
findSimilarFeedback,
feedbackOptions, feedbackOptions,
getFeedbackForSubmitter, getFeedbackForSubmitter,
getFeedbackForAdmin,
getFeedbackAttachment,
getFeedbackForViewer,
listFeedbackForAdmin, listFeedbackForAdmin,
listMyFeedback, listMyFeedback,
listPublicFeedback, listPublicFeedback,
markFeedbackViewed, markFeedbackViewed,
notificationSummary notificationSummary,
supportFeedback
} = require("../services/feedback"); } = require("../services/feedback");
function ensureSessionSecret() { function ensureSessionSecret() {
@ -1683,6 +1689,60 @@ function setFlash(req, type, message) {
req.session.flash = { type, message }; req.session.flash = { type, message };
} }
function screenshotExtension(mime) {
if (mime === "image/jpeg") return ".jpg";
if (mime === "image/webp") return ".webp";
return ".png";
}
function feedbackAttachmentExtension(file = {}) {
const original = path.extname(file.originalname || "").slice(0, 12).toLowerCase();
if ([".png", ".jpg", ".jpeg", ".webp", ".pdf", ".txt"].includes(original)) return original;
if (file.mimetype === "application/pdf") return ".pdf";
if (file.mimetype === "text/plain") return ".txt";
return screenshotExtension(file.mimetype);
}
function normalizeFeedbackRequestBody(body = {}) {
return {
...body,
target_metadata: parseBodyJson(body.target_metadata),
diagnostics: parseBodyJson(body.diagnostics)
};
}
function parseBodyJson(value) {
if (value && typeof value === "object") return value;
try {
const parsed = JSON.parse(String(value || "{}"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function resolveFeedbackScreenshotPath(relativePath, screenshotDir) {
const normalized = String(relativePath || "").replace(/\\/g, "/");
const prefix = "feedback/screenshots/";
if (!normalized.startsWith(prefix)) return null;
const fileName = path.basename(normalized.slice(prefix.length));
if (!fileName || fileName !== normalized.slice(prefix.length)) return null;
const target = path.resolve(screenshotDir, fileName);
const root = path.resolve(screenshotDir);
return target.startsWith(`${root}${path.sep}`) ? target : null;
}
function resolveFeedbackAttachmentPath(relativePath, attachmentDir) {
const normalized = String(relativePath || "").replace(/\\/g, "/");
const prefix = "feedback/attachments/";
if (!normalized.startsWith(prefix)) return null;
const fileName = path.basename(normalized.slice(prefix.length));
if (!fileName || fileName !== normalized.slice(prefix.length)) return null;
const target = path.resolve(attachmentDir, fileName);
const root = path.resolve(attachmentDir);
return target.startsWith(`${root}${path.sep}`) ? target : null;
}
function getThemeSettings() { function getThemeSettings() {
return getActiveTheme(); return getActiveTheme();
} }
@ -2488,6 +2548,10 @@ function createWebServer({ loadPlugins, discordClient }) {
const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons"); const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons");
fs.mkdirSync(navIconDir, { recursive: true }); fs.mkdirSync(navIconDir, { recursive: true });
app.use("/assets/nav-icons", express.static(navIconDir)); app.use("/assets/nav-icons", express.static(navIconDir));
const feedbackScreenshotDir = path.join(__dirname, "..", "..", "data", "feedback", "screenshots");
fs.mkdirSync(feedbackScreenshotDir, { recursive: true });
const feedbackAttachmentDir = path.join(__dirname, "..", "..", "data", "feedback", "attachments");
fs.mkdirSync(feedbackAttachmentDir, { recursive: true });
const upload = multer ? multer({ dest: uploadDir }) : null; const upload = multer ? multer({ dest: uploadDir }) : null;
const uploadSingle = (fieldName) => { const uploadSingle = (fieldName) => {
if (!upload) { if (!upload) {
@ -2522,6 +2586,41 @@ function createWebServer({ loadPlugins, discordClient }) {
} }
return navIconUpload.single("icon_file")(req, _res, next); return navIconUpload.single("icon_file")(req, _res, next);
}; };
const feedbackScreenshotUpload =
multer &&
multer({
storage: multer.diskStorage({
destination: (_req, file, cb) => cb(null, file.fieldname === "attachments" ? feedbackAttachmentDir : feedbackScreenshotDir),
filename: (_req, file, cb) => {
const ext = file.fieldname === "attachments" ? feedbackAttachmentExtension(file) : screenshotExtension(file.mimetype);
cb(null, `${crypto.randomUUID()}${ext}`);
}
}),
limits: { fileSize: 8 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.fieldname === "screenshot" && ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype)) {
return cb(null, true);
}
if (file.fieldname === "attachments" && ["image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain"].includes(file.mimetype)) {
return cb(null, true);
}
cb(new Error("Only PNG, JPEG, WebP, PDF, or text feedback files are allowed."));
}
});
const feedbackUploadFields = (req, res, next) => {
if (!feedbackScreenshotUpload) {
return next();
}
return feedbackScreenshotUpload.fields([
{ name: "screenshot", maxCount: 1 },
{ name: "attachments", maxCount: 3 }
])(req, res, (error) => {
if (error) {
req.feedbackUploadError = error.message;
}
next();
});
};
const navItems = []; const navItems = [];
const profileSections = []; const profileSections = [];
@ -2579,15 +2678,59 @@ function createWebServer({ loadPlugins, discordClient }) {
res.status(400).json({ error: error.message }); res.status(400).json({ error: error.message });
} }
}); });
app.post("/api/feedback", requireAuth, (req, res) => { app.get("/api/feedback/similar", requireAuth, (req, res) => {
try { try {
const entry = createFeedback(req.body, req.session.user); res.json({
ok: true,
matches: findSimilarFeedback(req.query, {
userId: req.session.user.id,
limit: req.query.limit || 5
})
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/feedback/:id/support", requireAuth, (req, res) => {
try {
const support_count = supportFeedback(req.params.id, req.session.user);
res.json({ ok: true, support_count });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post("/api/feedback", requireAuth, feedbackUploadFields, (req, res) => {
try {
if (req.feedbackUploadError) {
throw new Error(req.feedbackUploadError);
}
const body = normalizeFeedbackRequestBody(req.body);
const screenshotFile = req.files?.screenshot?.[0] || null;
const attachmentFiles = req.files?.attachments || [];
const entry = createFeedback(body, req.session.user, {
screenshot: screenshotFile
? {
path: `feedback/screenshots/${screenshotFile.filename}`,
mime: screenshotFile.mimetype,
size: screenshotFile.size
}
: null,
attachments: attachmentFiles.map((file) => ({
path: `feedback/attachments/${file.filename}`,
mime: file.mimetype,
size: file.size,
original_name: file.originalname
}))
});
res.status(201).json({ res.status(201).json({
ok: true, ok: true,
id: entry.id, id: entry.id,
message: "Feedback submitted. You can review it from the Feedback page." message: "Feedback submitted. You can review it from the Feedback page."
}); });
} catch (error) { } catch (error) {
[...(req.files?.screenshot || []), ...(req.files?.attachments || [])].forEach((file) => {
if (file?.path) fs.rmSync(file.path, { force: true });
});
res.status(400).json({ error: error.message }); res.status(400).json({ error: error.message });
} }
}); });
@ -3698,7 +3841,7 @@ function createWebServer({ loadPlugins, discordClient }) {
markFeedbackViewed(req.session.user.id); markFeedbackViewed(req.session.user.id);
res.locals.feedbackNotifications = notificationSummary(req.session.user.id); res.locals.feedbackNotifications = notificationSummary(req.session.user.id);
const selected = req.query.id const selected = req.query.id
? getFeedbackForSubmitter(String(req.query.id), req.session.user.id) ? getFeedbackForViewer(String(req.query.id), req.session.user.id)
: null; : null;
res.render("feedback", { res.render("feedback", {
title: "Feedback", title: "Feedback",
@ -3719,6 +3862,64 @@ function createWebServer({ loadPlugins, discordClient }) {
res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`); res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`);
}); });
app.post("/feedback/:id/support", requireAuth, (req, res) => {
try {
supportFeedback(req.params.id, req.session.user);
setFlash(req, "success", "Feedback supported.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/feedback");
});
app.get("/feedback/:id/screenshot", requireAuth, (req, res) => {
const entry = req.session.user?.isAdmin
? getFeedbackForAdmin(req.params.id)
: getFeedbackForSubmitter(req.params.id, req.session.user.id);
if (!entry?.screenshot?.path) {
return res.status(404).render("error", {
title: "Screenshot not found",
message: "That feedback screenshot is not available."
});
}
const screenshotPath = resolveFeedbackScreenshotPath(entry.screenshot.path, feedbackScreenshotDir);
if (!screenshotPath || !fs.existsSync(screenshotPath)) {
return res.status(404).render("error", {
title: "Screenshot not found",
message: "That feedback screenshot is no longer available."
});
}
res.setHeader("Content-Type", entry.screenshot.mime || "image/png");
res.setHeader("Cache-Control", "private, no-store");
res.sendFile(screenshotPath);
});
app.get("/feedback/:id/attachments/:attachmentId", requireAuth, (req, res) => {
const attachment = getFeedbackAttachment(
req.params.id,
req.params.attachmentId,
req.session.user.id,
Boolean(req.session.user?.isAdmin)
);
if (!attachment?.storage_path) {
return res.status(404).render("error", {
title: "Attachment not found",
message: "That feedback attachment is not available."
});
}
const attachmentPath = resolveFeedbackAttachmentPath(attachment.storage_path, feedbackAttachmentDir);
if (!attachmentPath || !fs.existsSync(attachmentPath)) {
return res.status(404).render("error", {
title: "Attachment not found",
message: "That feedback attachment is no longer available."
});
}
res.setHeader("Content-Type", attachment.mime || "application/octet-stream");
res.setHeader("Cache-Control", "private, no-store");
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(attachment.original_name || "attachment")}"`);
res.sendFile(attachmentPath);
});
app.get("/health", (req, res) => { app.get("/health", (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.json({ ok: true, ts: Date.now() }); res.json({ ok: true, ts: Date.now() });
@ -4987,7 +5188,10 @@ function createWebServer({ loadPlugins, discordClient }) {
category: req.query.category || "", category: req.query.category || "",
severity: req.query.severity || "", severity: req.query.severity || "",
scope: req.query.scope || "", scope: req.query.scope || "",
area: req.query.area || "",
submitter: req.query.submitter || "", submitter: req.query.submitter || "",
date_from: req.query.date_from || "",
date_to: req.query.date_to || "",
needs_action: req.query.needs_action || "", needs_action: req.query.needs_action || "",
sort: req.query.sort || "last_activity" sort: req.query.sort || "last_activity"
}, },
@ -4997,18 +5201,94 @@ function createWebServer({ loadPlugins, discordClient }) {
app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => { app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => {
try { try {
adminUpdateFeedback(req.params.id, req.body, req.session.user); const body = { ...req.body };
setFlash(req, "success", "Feedback updated."); if (body.review_action === "finalize") {
body.status = "closed";
body.status_note = body.status_note || "Finalized and closed.";
} else if (body.review_action === "reopen") {
body.status = "reviewed";
body.status_note = body.status_note || "Reopened for review.";
}
adminUpdateFeedback(req.params.id, body, req.session.user);
setFlash(req, "success", body.review_action === "finalize"
? "Feedback finalized and closed."
: body.review_action === "reopen"
? "Feedback reopened."
: "Feedback updated.");
} catch (error) { } catch (error) {
setFlash(req, "error", error.message); setFlash(req, "error", error.message);
} }
res.redirect(req.get("referer") || "/admin/feedback"); res.redirect(req.get("referer") || "/admin/feedback");
}); });
app.post("/admin/feedback/:id/todo", requireRole("admin"), (req, res) => { app.post("/admin/feedback/:id/finalize", requireRole("admin"), (req, res) => {
try { try {
convertFeedbackToTodo(req.params.id, req.body.todo_text, req.session.user); adminUpdateFeedback(req.params.id, {
setFlash(req, "success", "Feedback converted to TODO."); ...req.body,
status: "closed",
status_note: req.body.status_note || "Finalized and closed."
}, req.session.user);
setFlash(req, "success", "Feedback finalized and closed.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.post("/admin/feedback/:id/reopen", requireRole("admin"), (req, res) => {
try {
adminUpdateFeedback(req.params.id, {
...req.body,
status: "reviewed",
status_note: req.body.status_note || "Reopened for review."
}, req.session.user);
setFlash(req, "success", "Feedback reopened.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect(req.get("referer") || "/admin/feedback");
});
app.post("/admin/feedback/:id/delete", requireRole("admin"), (req, res) => {
try {
deleteFeedback(req.params.id, {
deleteScreenshot(relativePath) {
const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir);
if (screenshotPath) {
fs.rmSync(screenshotPath, { force: true });
}
},
deleteAttachment(relativePath) {
const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir);
if (attachmentPath) {
fs.rmSync(attachmentPath, { force: true });
}
}
});
setFlash(req, "success", "Feedback permanently deleted.");
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect("/admin/feedback");
});
app.post("/admin/feedback/:id/cleanup", requireRole("admin"), (req, res) => {
try {
cleanupFeedback(req.params.id, req.body, req.session.user, {
deleteScreenshot(relativePath) {
const screenshotPath = resolveFeedbackScreenshotPath(relativePath, feedbackScreenshotDir);
if (screenshotPath) {
fs.rmSync(screenshotPath, { force: true });
}
},
deleteAttachment(relativePath) {
const attachmentPath = resolveFeedbackAttachmentPath(relativePath, feedbackAttachmentDir);
if (attachmentPath) {
fs.rmSync(attachmentPath, { force: true });
}
}
});
setFlash(req, "success", "Feedback data cleaned.");
} catch (error) { } catch (error) {
setFlash(req, "error", error.message); setFlash(req, "error", error.message);
} }

View File

@ -3,7 +3,7 @@
<%- include("partials/page-header", { <%- include("partials/page-header", {
eyebrow: "Administration", eyebrow: "Administration",
pageTitle: "Feedback review", pageTitle: "Feedback review",
description: "Review core feedback, reply to submitters, record private work notes, and convert useful reports into TODO entries." description: "Review core feedback, reply to submitters, record private work notes, and close or delete resolved reports."
}) %> }) %>
<form method="get" action="/admin/feedback" class="log-controls feedback-admin-filters"> <form method="get" action="/admin/feedback" class="log-controls feedback-admin-filters">
<label> <label>
@ -42,10 +42,22 @@
<% }) %> <% }) %>
</select> </select>
</label> </label>
<label>
<span>Plugin/area</span>
<input name="area" value="<%= filters.area %>" placeholder="URL, plugin, page, area" />
</label>
<label> <label>
<span>Submitter</span> <span>Submitter</span>
<input name="submitter" value="<%= filters.submitter %>" placeholder="Name or user ID" /> <input name="submitter" value="<%= filters.submitter %>" placeholder="Name or user ID" />
</label> </label>
<label>
<span>From</span>
<input type="date" name="date_from" value="<%= filters.date_from %>" />
</label>
<label>
<span>To</span>
<input type="date" name="date_to" value="<%= filters.date_to %>" />
</label>
<label> <label>
<span>Sort</span> <span>Sort</span>
<select name="sort"> <select name="sort">
@ -81,11 +93,11 @@
<% } else { %> <% } else { %>
<div class="feedback-admin-list"> <div class="feedback-admin-list">
<% feedbackItems.forEach((item) => { %> <% feedbackItems.forEach((item) => { %>
<details class="feedback-admin-item" <%= item.status === "new" || item.status === "needs_more_context" ? "open" : "" %>> <details class="feedback-admin-item">
<summary> <summary>
<span> <span>
<strong><%= item.summary %></strong> <strong><%= item.summary %></strong>
<small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %></small> <small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %> · <%= item.support_count || 0 %> also affected</small>
</span> </span>
<span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span> <span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span>
</summary> </summary>
@ -93,6 +105,7 @@
<div class="feedback-detail-grid"> <div class="feedback-detail-grid">
<div><span>Submitter</span><strong><%= item.submitter_name || item.submitter_id %></strong></div> <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>Severity</span><strong><%= item.severity_label %></strong></div>
<div><span>Also affected</span><strong><%= item.support_count || 0 %></strong></div>
<div><span>Created</span><strong><%= new Date(item.created_at).toLocaleString() %></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><span>Last activity</span><strong><%= new Date(item.last_activity_at).toLocaleString() %></strong></div>
</div> </div>
@ -115,6 +128,54 @@
</div> </div>
<% if (item.current_url) { %><p><strong>URL:</strong> <a href="<%= item.current_url %>"><%= item.current_url %></a></p><% } %> <% 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><% } %> <% if (item.page_title) { %><p><strong>Page title:</strong> <%= item.page_title %></p><% } %>
<% if (item.screenshot) { %>
<p><strong>Screenshot:</strong> <a href="/feedback/<%= item.id %>/screenshot" target="_blank" rel="noopener">Open attached screenshot</a> <span class="hint"><%= Math.max(1, Math.round((item.screenshot.size || 0) / 1024)) %> KB</span></p>
<% } %>
<% if (item.attachments && item.attachments.length) { %>
<p><strong>Attachments:</strong></p>
<div class="feedback-attachment-list">
<% item.attachments.forEach((attachment) => { %>
<a class="button subtle" href="/feedback/<%= item.id %>/attachments/<%= attachment.id %>"><%= attachment.original_name %> (<%= Math.max(1, Math.round((attachment.size || 0) / 1024)) %> KB)</a>
<% }) %>
</div>
<% } %>
</details>
<details class="feedback-metadata">
<summary>Sensitive data cleanup</summary>
<form
method="post"
action="/admin/feedback/<%= item.id %>/cleanup"
class="form-grid"
data-confirm-mode="modal"
data-confirm-title="Clean feedback data"
data-confirm-text="This removes selected diagnostic or attachment data from this feedback item. The action is recorded as a private work note."
data-confirm-label="Clean data"
>
<label class="checkbox-inline">
<input type="checkbox" name="clear_screenshot" value="1" <%= item.screenshot ? "" : "disabled" %> />
<span>Remove screenshot</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_attachments" value="1" <%= item.attachments && item.attachments.length ? "" : "disabled" %> />
<span>Remove attachments</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_diagnostics" value="1" />
<span>Clear diagnostics</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_target_metadata" value="1" />
<span>Clear target metadata</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" name="clear_admin_reply" value="1" />
<span>Clear public admin reply</span>
</label>
<div class="field full button-group">
<button class="button danger" type="submit">Clean selected data</button>
</div>
</form>
</details> </details>
<div class="feedback-comments"> <div class="feedback-comments">
@ -140,7 +201,7 @@
</ul> </ul>
</details> </details>
<form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form"> <form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form" data-confirm-mode="modal">
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select name="status"> <select name="status">
@ -185,24 +246,25 @@
<label>Private work note</label> <label>Private work note</label>
<textarea name="work_note" rows="3" placeholder="Only admins can see this note."></textarea> <textarea name="work_note" rows="3" placeholder="Only admins can see this note."></textarea>
</div> </div>
<div class="field full button-group"> <div class="field full button-group centered">
<button class="button" type="submit">Save review</button> <button class="button" type="submit">Save review</button>
<% if (item.status === "closed") { %>
<button class="button subtle" type="submit" name="review_action" value="reopen">Reopen</button>
<% } else { %>
<button class="button subtle" type="submit" name="review_action" value="finalize">Finalize &amp; Close</button>
<% } %>
<button
class="button danger"
type="submit"
formaction="/admin/feedback/<%= item.id %>/delete"
formmethod="post"
data-confirm-mode="modal"
data-confirm-title="Delete feedback permanently"
data-confirm-text="This permanently deletes the feedback, comments, private notes, status history, and attached screenshot."
data-confirm-label="Delete feedback"
>Delete</button>
</div> </div>
</form> </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> </div>
</details> </details>
<% }) %> <% }) %>

View File

@ -55,6 +55,9 @@
<div><span>Scope</span><strong><%= selectedFeedback.scope_label_display %></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><span>Submitted</span><strong><%= new Date(selectedFeedback.created_at).toLocaleString() %></strong></div>
</div> </div>
<% if (!selectedFeedback.is_mine) { %>
<div class="callout info"><strong>Public feedback</strong><p>You can mark that this affects you too or add public context without seeing private diagnostics or attachments.</p></div>
<% } %>
<div class="feedback-copy-block"> <div class="feedback-copy-block">
<strong>Description</strong> <strong>Description</strong>
<p><%= selectedFeedback.description %></p> <p><%= selectedFeedback.description %></p>
@ -71,6 +74,22 @@
<% if (selectedFeedback.admin_reply) { %> <% if (selectedFeedback.admin_reply) { %>
<div class="callout success"><strong>Admin reply</strong><p><%= selectedFeedback.admin_reply %></p></div> <div class="callout success"><strong>Admin reply</strong><p><%= selectedFeedback.admin_reply %></p></div>
<% } %> <% } %>
<% if (selectedFeedback.screenshot) { %>
<div class="feedback-copy-block">
<strong>Attached screenshot</strong>
<p><a class="button subtle" href="/feedback/<%= selectedFeedback.id %>/screenshot" target="_blank" rel="noopener">Open screenshot</a></p>
</div>
<% } %>
<% if (selectedFeedback.attachments && selectedFeedback.attachments.length) { %>
<div class="feedback-copy-block">
<strong>Attachments</strong>
<div class="feedback-attachment-list">
<% selectedFeedback.attachments.forEach((attachment) => { %>
<a class="button subtle" href="/feedback/<%= selectedFeedback.id %>/attachments/<%= attachment.id %>"><%= attachment.original_name %> (<%= Math.max(1, Math.round((attachment.size || 0) / 1024)) %> KB)</a>
<% }) %>
</div>
</div>
<% } %>
<h3>Conversation</h3> <h3>Conversation</h3>
<% if (!selectedFeedback.comments.length) { %> <% if (!selectedFeedback.comments.length) { %>
<p class="hint">No comments yet.</p> <p class="hint">No comments yet.</p>
@ -85,11 +104,18 @@
<% }) %> <% }) %>
</div> </div>
<% } %> <% } %>
<% if (!selectedFeedback.is_mine && !selectedFeedback.supported_by_me) { %>
<form method="post" action="/feedback/<%= selectedFeedback.id %>/support" class="inline-form">
<button type="submit" class="button subtle">This affects me too</button>
</form>
<% } else if (selectedFeedback.supported_by_me) { %>
<span class="hint">Marked as affecting you too.</span>
<% } %>
<form method="post" action="/feedback/<%= selectedFeedback.id %>/comment" class="form-grid"> <form method="post" action="/feedback/<%= selectedFeedback.id %>/comment" class="form-grid">
<div class="field full"> <div class="field full">
<label>Add more context</label> <label><%= selectedFeedback.is_mine ? "Add more context" : "Add public context" %></label>
<textarea name="comment" rows="4" required></textarea> <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> <span class="hint"><%= selectedFeedback.is_mine ? "Use this when an admin asks for more detail or you have extra context." : "Use this when you have the same issue or can add useful public detail." %></span>
</div> </div>
<div class="field full"> <div class="field full">
<button type="submit" class="button">Add comment</button> <button type="submit" class="button">Add comment</button>
@ -121,11 +147,21 @@
<td> <td>
<%= item.summary %> <%= item.summary %>
<% if (item.is_mine) { %><span class="badge">Mine</span><% } %> <% if (item.is_mine) { %><span class="badge">Mine</span><% } %>
<% if (item.support_count) { %><span class="badge"><%= item.support_count %> also affected</span><% } %>
</td> </td>
<td><%= item.category_label %></td> <td><%= item.category_label %></td>
<td><%= item.scope_label_display %></td> <td><%= item.scope_label_display %></td>
<td><span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span></td> <td><span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span></td>
<td><%= new Date(item.updated_at).toLocaleString() %></td> <td>
<%= new Date(item.updated_at).toLocaleString() %>
<% if (!item.is_mine && !item.supported_by_me) { %>
<form method="post" action="/feedback/<%= item.id %>/support" class="inline-form">
<button type="submit" class="button subtle">This affects me too</button>
</form>
<% } else if (item.supported_by_me) { %>
<span class="hint">Marked as affecting you too.</span>
<% } %>
</td>
</tr> </tr>
<% }) %> <% }) %>
</tbody> </tbody>

View File

@ -40,6 +40,11 @@
<label>Short summary</label> <label>Short summary</label>
<input name="summary" maxlength="140" required placeholder="Example: Save button does nothing" /> <input name="summary" maxlength="140" required placeholder="Example: Save button does nothing" />
</div> </div>
<div class="field full feedback-similar" data-feedback-similar hidden>
<strong>Possibly related feedback</strong>
<p class="hint">If one of these matches your issue, mark that it affects you too instead of creating a duplicate.</p>
<div class="feedback-similar-list" data-feedback-similar-list></div>
</div>
<div class="field"> <div class="field">
<label>Category</label> <label>Category</label>
<select name="category" data-feedback-category required></select> <select name="category" data-feedback-category required></select>
@ -53,14 +58,39 @@
<select name="scope_type" data-feedback-scope-type required></select> <select name="scope_type" data-feedback-scope-type required></select>
</div> </div>
<div class="field"> <div class="field">
<label>Scope label</label> <label>Detected target</label>
<input name="scope_label" data-feedback-scope-label placeholder="Auto-detected or custom" /> <input name="scope_label" data-feedback-scope-label readonly />
<span class="hint">Generated from the selected feedback scope and current page or clicked element.</span>
</div>
<div class="field full feedback-screenshot-field" data-feedback-screenshot-field>
<label>Screenshot <span class="hint">optional</span></label>
<div class="feedback-screenshot-guide">
<strong>Want to include a screenshot?</strong>
<p><span data-feedback-screenshot-keybind>Use your operating system screenshot shortcut</span>, then return here and choose <strong>Paste screenshot</strong>. This form stays open while you take the screenshot.</p>
<span class="hint">Only attach a screenshot if you want admins to see it. Remove secrets, tokens, private messages, and sensitive fields first.</span>
</div>
<div class="feedback-screenshot-actions">
<label class="button subtle" for="feedback-screenshot-file">Choose image</label>
<input id="feedback-screenshot-file" type="file" accept="image/png,image/jpeg,image/webp" data-feedback-screenshot-file hidden />
<button type="button" class="button subtle" data-feedback-paste-screenshot>Paste screenshot</button>
<button type="button" class="button subtle" data-feedback-remove-screenshot hidden>Remove screenshot</button>
</div>
<div class="feedback-screenshot-preview" data-feedback-screenshot-preview hidden>
<img alt="Attached feedback screenshot preview" data-feedback-screenshot-image />
<span data-feedback-screenshot-meta></span>
</div>
<span class="hint" data-feedback-screenshot-status></span>
</div> </div>
<div class="field full"> <div class="field full">
<label>Description</label> <label>Description</label>
<textarea name="description" rows="5" required placeholder="Describe one specific issue or suggestion."></textarea> <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> <span class="hint">Please submit one feedback item per issue. Do not include passwords, tokens, or private messages.</span>
</div> </div>
<div class="field full">
<label>Attachments <span class="hint">optional</span></label>
<input type="file" name="attachments" multiple accept="image/png,image/jpeg,image/webp,application/pdf,text/plain" data-feedback-attachments />
<span class="hint">Up to three files, 8 MB each. Supported types: PNG, JPEG, WebP, PDF, and plain text. Attachments are visible only to you and admins.</span>
</div>
<div class="field full"> <div class="field full">
<label>Steps to reproduce <span class="hint">optional</span></label> <label>Steps to reproduce <span class="hint">optional</span></label>
<textarea name="steps_to_reproduce" rows="3"></textarea> <textarea name="steps_to_reproduce" rows="3"></textarea>
@ -80,6 +110,13 @@
</label> </label>
<span class="hint">This attaches your browser name, page size, language, URL, and page title. Field values are not collected.</span> <span class="hint">This attaches your browser name, page size, language, URL, and page title. Field values are not collected.</span>
</div> </div>
<div class="field full">
<label class="checkbox-inline">
<input type="checkbox" data-feedback-include-dom />
<span>Include sanitized page structure</span>
</label>
<span class="hint">This attaches a limited text outline of nearby page elements. Input values, passwords, tokens, and hidden fields are not collected.</span>
</div>
<div class="field full feedback-submit-status" data-feedback-status aria-live="polite"></div> <div class="field full feedback-submit-status" data-feedback-status aria-live="polite"></div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="button subtle" data-feedback-close>Cancel</button> <button type="button" class="button subtle" data-feedback-close>Cancel</button>
@ -89,14 +126,14 @@
</div> </div>
</div> </div>
<div class="lumi-context-menu" data-lumi-context-menu hidden role="menu" aria-label="Page actions"> <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="back" data-context-tooltip="Go back to the page you were viewing before this one.">Back</button>
<button type="button" role="menuitem" data-context-action="forward">Forward</button> <button type="button" role="menuitem" data-context-action="forward" data-context-tooltip="Return to the next page after going back.">Forward</button>
<button type="button" role="menuitem" data-context-action="copy">Copy</button> <button type="button" role="menuitem" data-context-action="copy" data-context-tooltip="Copy the selected text, or copy the image you clicked when the browser allows it.">Copy</button>
<button type="button" role="menuitem" data-context-action="cut">Cut</button> <button type="button" role="menuitem" data-context-action="cut" data-context-tooltip="Remove the selected text from this field and place it on the clipboard.">Cut</button>
<button type="button" role="menuitem" data-context-action="paste">Paste</button> <button type="button" role="menuitem" data-context-action="paste" data-context-tooltip="Insert the current clipboard text into the active field.">Paste</button>
<button type="button" role="menuitem" data-context-action="link">Link to here</button> <button type="button" role="menuitem" data-context-action="link" data-context-tooltip="Copy a link to this page, section, or nearby anchored element.">Link to here</button>
<button type="button" role="menuitem" data-context-action="reload">Hard reload</button> <button type="button" role="menuitem" data-context-action="reload" data-context-tooltip="Reload this page while asking the browser to refresh cached files.">Hard reload</button>
<button type="button" role="menuitem" data-context-action="feedback">Feedback</button> <button type="button" role="menuitem" data-context-action="feedback" data-context-tooltip="Create feedback for admins. Right-click a specific element to attach it as context.">Feedback</button>
</div> </div>
<% } %> <% } %>
<div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true"> <div class="modal-backdrop destructive-confirm-modal" data-destructive-modal aria-hidden="true">