Merge experimental feedback system
This commit is contained in:
commit
7cb365a7ed
421
TODO.md
421
TODO.md
@ -122,8 +122,429 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
|||||||
- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values.
|
- Add examples for fields that expect URLs, model names, provider names, paths, selectors, or structured values.
|
||||||
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
||||||
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
||||||
|
|
||||||
|
## Core Feedback System
|
||||||
|
|
||||||
|
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.
|
||||||
|
- Add a persistent feedback entry point somewhere logical in the UI.
|
||||||
|
- Add `/feedback` under the Community navbar section for logged-in users.
|
||||||
|
- Add `/admin/feedback` as the admin review entry point.
|
||||||
|
- Ensure the feedback system is core-level functionality and can cover any area, scope, feature, page, plugin, or UI element.
|
||||||
|
- Keep the AI Improvement Center separate from this system; it should continue handling AI reply quality feedback.
|
||||||
|
- Add a `wrong tool` classification to the AI Improvement Center for cases where the AI used the wrong tool, action, integration, or capability.
|
||||||
|
|
||||||
|
### Feedback Categories and Scope
|
||||||
|
|
||||||
|
- Use generic feedback categories based on issue type rather than feature-specific categories.
|
||||||
|
- Suggested categories:
|
||||||
|
- bug
|
||||||
|
- confusing wording
|
||||||
|
- broken interaction
|
||||||
|
- visual/layout issue
|
||||||
|
- accessibility issue
|
||||||
|
- missing feature
|
||||||
|
- improvement suggestion
|
||||||
|
- performance issue
|
||||||
|
- permission/access issue
|
||||||
|
- unexpected behavior
|
||||||
|
- other
|
||||||
|
- Allow feedback to target:
|
||||||
|
- the whole page
|
||||||
|
- a specific clicked UI element
|
||||||
|
- the current feature/page
|
||||||
|
- a plugin
|
||||||
|
- a broad system area
|
||||||
|
- other/custom scope
|
||||||
|
- Encourage users to submit one feedback item per issue.
|
||||||
|
- Add helper text explaining that small, specific feedback reports are preferred over broad combined reports.
|
||||||
|
|
||||||
|
### Feedback Submission UI
|
||||||
|
|
||||||
|
- Add a feedback modal for creating feedback.
|
||||||
|
- The modal should be accessible from the persistent feedback button and from the custom context menu.
|
||||||
|
- Require logged-in user identity for all feedback submissions.
|
||||||
|
- Allow the user to enter:
|
||||||
|
- short summary/title
|
||||||
|
- category
|
||||||
|
- severity/priority
|
||||||
|
- scope/target
|
||||||
|
- detailed description
|
||||||
|
- optional steps to reproduce
|
||||||
|
- optional expected behavior
|
||||||
|
- optional actual behavior
|
||||||
|
- Support severity/priority values such as:
|
||||||
|
- minor
|
||||||
|
- confusing
|
||||||
|
- broken
|
||||||
|
- urgent
|
||||||
|
- security/sensitive
|
||||||
|
- suggestion
|
||||||
|
- Pre-fill feedback scope based on the current page or clicked element when available.
|
||||||
|
- Allow users to change the auto-detected scope before submitting.
|
||||||
|
- Show clear confirmation after feedback is submitted.
|
||||||
|
|
||||||
|
### Site-Wide Custom Context Menu
|
||||||
|
|
||||||
|
- Replace the standard browser right-click context menu across Lumi with a custom site-wide context menu.
|
||||||
|
- Include the following actions:
|
||||||
|
- Back
|
||||||
|
- Forward
|
||||||
|
- Copy
|
||||||
|
- Cut
|
||||||
|
- Paste
|
||||||
|
- Link to here
|
||||||
|
- Hard reload
|
||||||
|
- Feedback
|
||||||
|
- Ensure context menu actions respect browser permissions and limitations.
|
||||||
|
- Ensure copy, cut, and paste only appear or work where appropriate.
|
||||||
|
- Implement “Link to here” by creating a URL to the current page and, when possible, the clicked element or section.
|
||||||
|
- Implement “Hard reload” as a cache-bypassing reload comparable to a developer hard refresh where possible.
|
||||||
|
- Add the Feedback action to open the feedback modal with the clicked element as the target.
|
||||||
|
- Preserve keyboard accessibility and provide fallback behavior if custom context menu features are unavailable in a browser.
|
||||||
|
|
||||||
|
### Element-Targeted Feedback
|
||||||
|
|
||||||
|
- When feedback is opened from the context menu, capture the clicked element as the feedback target.
|
||||||
|
- Store safe metadata about the clicked element, such as:
|
||||||
|
- selector or generated stable path
|
||||||
|
- element tag
|
||||||
|
- visible text snippet
|
||||||
|
- aria-label/title/role when available
|
||||||
|
- nearest form label or heading
|
||||||
|
- page URL
|
||||||
|
- page title
|
||||||
|
- viewport size
|
||||||
|
- Do not store sensitive field values from passwords, tokens, secrets, or private inputs.
|
||||||
|
- Allow users to choose whether feedback is about the clicked element, the whole page, the current feature, a plugin, or another scope.
|
||||||
|
- Highlight the selected target while the feedback modal is open when practical.
|
||||||
|
- Remove the highlight when the modal closes.
|
||||||
|
|
||||||
|
### Optional Screenshot Support
|
||||||
|
|
||||||
|
- Add optional screenshot attachment support without browser tab/window picker prompts.
|
||||||
|
- Screenshots must be opt-in by the submitting user.
|
||||||
|
- Do not require screenshots for feedback submission.
|
||||||
|
- Support uploaded screenshot image files.
|
||||||
|
- Support pasted screenshots from the clipboard where browsers allow it.
|
||||||
|
- Consider full-page screenshots only if a safe DOM-rendering implementation or browser extension path is added later.
|
||||||
|
- Store screenshots only when explicitly attached by the user.
|
||||||
|
- Clearly warn users not to include sensitive information in screenshots.
|
||||||
|
- Allow users to remove an attached screenshot before submitting.
|
||||||
|
|
||||||
|
### Optional Diagnostic Data
|
||||||
|
|
||||||
|
- Automatically attach basic non-sensitive context:
|
||||||
|
- user ID
|
||||||
|
- current URL
|
||||||
|
- page title
|
||||||
|
- timestamp
|
||||||
|
- selected scope
|
||||||
|
- target metadata if element-targeted
|
||||||
|
- Make browser/user agent and screenshot/DOM snapshot optional opt-in fields.
|
||||||
|
- If DOM snapshot support is added, sanitize it before storage.
|
||||||
|
- Do not capture sensitive form values, passwords, tokens, secrets, private messages, or hidden data.
|
||||||
|
- Clearly label optional diagnostic data so users understand what they are submitting.
|
||||||
|
|
||||||
|
### Feedback Visibility for Users
|
||||||
|
|
||||||
|
- On `/feedback`, show logged-in users a general list of current and past feedback that has not been deleted.
|
||||||
|
- Public/general feedback list should only show basic non-identifying information:
|
||||||
|
- summary
|
||||||
|
- category
|
||||||
|
- general scope
|
||||||
|
- status
|
||||||
|
- created date
|
||||||
|
- last updated date
|
||||||
|
- Do not expose submitter identity in the general feedback list.
|
||||||
|
- Allow users to see their own feedback in detail.
|
||||||
|
- Make it easy to distinguish the current user’s own feedback from general feedback.
|
||||||
|
- Consider a separate “My feedback” section or a clear badge/label in the shared list.
|
||||||
|
- Allow users to add comments to their own feedback when follow-up is needed.
|
||||||
|
- Allow users to see admin replies that are visible to the submitter.
|
||||||
|
|
||||||
|
### User Feedback Notifications
|
||||||
|
|
||||||
|
- Add per-user feedback notification badges.
|
||||||
|
- Show a green badge/sphere with the number of the user’s feedback items solved since they last opened the feedback center.
|
||||||
|
- Show a red badge/sphere with the number of the user’s feedback items marked as needing more context.
|
||||||
|
- Show a grey badge/sphere with the number of the user’s feedback items marked as declined, rejected, duplicate, won’t fix, not planned, or otherwise not being worked on.
|
||||||
|
- Reset or update badge counts when the user opens the feedback center and views the relevant items.
|
||||||
|
- Ensure notification badges only reflect the logged-in user’s own feedback.
|
||||||
|
|
||||||
|
### Feedback Statuses
|
||||||
|
|
||||||
|
- Support feedback statuses such as:
|
||||||
|
- new
|
||||||
|
- reviewed
|
||||||
|
- accepted
|
||||||
|
- planned
|
||||||
|
- in progress
|
||||||
|
- fixed
|
||||||
|
- solved
|
||||||
|
- needs more context
|
||||||
|
- duplicate
|
||||||
|
- rejected
|
||||||
|
- not planned
|
||||||
|
- won’t fix
|
||||||
|
- archived
|
||||||
|
- deleted
|
||||||
|
- Allow admins to change status from the admin review UI.
|
||||||
|
- Store status history with timestamps and actor information.
|
||||||
|
- Show user-friendly status names and explanations on `/feedback`.
|
||||||
|
|
||||||
|
### Replies, Comments, and Work Notes
|
||||||
|
|
||||||
|
- Allow admins to reply to feedback.
|
||||||
|
- Allow submitters to comment on their own feedback.
|
||||||
|
- Allow admins to add private internal work notes that are not visible to normal users.
|
||||||
|
- Allow admins to mark feedback as “needs more context”.
|
||||||
|
- When feedback is marked “needs more context”, notify the submitter through the red feedback badge.
|
||||||
|
- Allow the submitter to add additional context/comments after the request.
|
||||||
|
- Distinguish clearly between public/admin replies, submitter comments, and private admin work notes.
|
||||||
|
|
||||||
|
### Admin Feedback Review
|
||||||
|
|
||||||
|
- Add `/admin/feedback` for admin feedback review.
|
||||||
|
- Admins should be able to view full feedback details, including:
|
||||||
|
- submitter
|
||||||
|
- summary
|
||||||
|
- description
|
||||||
|
- category
|
||||||
|
- severity
|
||||||
|
- scope
|
||||||
|
- target metadata
|
||||||
|
- current URL
|
||||||
|
- browser/user agent if submitted
|
||||||
|
- screenshot if submitted
|
||||||
|
- DOM snapshot if submitted
|
||||||
|
- comments/replies
|
||||||
|
- work notes
|
||||||
|
- status history
|
||||||
|
- Add filters for:
|
||||||
|
- status
|
||||||
|
- category
|
||||||
|
- severity
|
||||||
|
- scope
|
||||||
|
- plugin/area
|
||||||
|
- submitter
|
||||||
|
- date
|
||||||
|
- needs admin action
|
||||||
|
- Add sorting by newest, oldest, severity, status, and last activity.
|
||||||
|
- Add admin actions:
|
||||||
|
- change status
|
||||||
|
- assign category
|
||||||
|
- change severity
|
||||||
|
- reply to submitter
|
||||||
|
- add internal work note
|
||||||
|
- request more context
|
||||||
|
- mark duplicate
|
||||||
|
- archive
|
||||||
|
- delete
|
||||||
|
- convert to OKF correction where relevant
|
||||||
|
- link to existing issue/correction
|
||||||
|
|
||||||
|
### OKF and AI Improvement Integration
|
||||||
|
|
||||||
|
- Keep this feedback system separate from AI reply feedback.
|
||||||
|
- Do not use this feedback system to rate individual AI replies.
|
||||||
|
- Use the AI Improvement Center for AI answer quality, wrong answers, bad context, and correction review.
|
||||||
|
- Add `wrong tool` as a classification in the AI Improvement Center.
|
||||||
|
- Allow core feedback to cover AI features, AI UI, AI configuration, and AI workflow problems.
|
||||||
|
- Allow admins to convert relevant core feedback into OKF corrections only when the feedback concerns documentation, feature behavior, terminology, or community/system knowledge.
|
||||||
|
- Do not automatically modify OKF based on general feedback.
|
||||||
|
|
||||||
|
### Rate Limits and Abuse Prevention
|
||||||
|
|
||||||
|
- Add moderate rate limits for feedback submissions.
|
||||||
|
- Rate limits should prevent spam without blocking normal use.
|
||||||
|
- Encourage one feedback item per issue.
|
||||||
|
- Add validation to prevent empty or extremely vague feedback.
|
||||||
|
- Add optional duplicate detection based on similar title, scope, and page.
|
||||||
|
- If similar existing feedback exists, suggest adding a public comment or marking that it affects the user too instead of creating a duplicate.
|
||||||
|
- Allow admins to delete or archive abusive/spam feedback.
|
||||||
|
|
||||||
|
### Data Storage and Preservation
|
||||||
|
|
||||||
|
- Store feedback data in SQLite or the existing app storage system.
|
||||||
|
- Store screenshots and optional attachments in a protected feedback uploads/storage directory.
|
||||||
|
- Preserve feedback data across repo updates, ZIP updates, migrations, and recovery operations.
|
||||||
|
- Include feedback data in the protected user-data list for backups and update preservation.
|
||||||
|
- Never overwrite feedback data during core/plugin updates.
|
||||||
|
- Include feedback data in backup/restore planning.
|
||||||
|
- Store enough metadata for auditability:
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
- submitter_id
|
||||||
|
- assigned_admin_id if used
|
||||||
|
- status history
|
||||||
|
- comments/replies
|
||||||
|
- linked issues/corrections
|
||||||
|
|
||||||
|
### Privacy and Safety
|
||||||
|
|
||||||
|
- Do not expose submitter identity in the general feedback list.
|
||||||
|
- Only admins should see submitter identity and full diagnostic details.
|
||||||
|
- Users should only see full detail for their own feedback.
|
||||||
|
- Sanitize all submitted HTML/text before rendering.
|
||||||
|
- Avoid capturing sensitive form values.
|
||||||
|
- Make optional diagnostic capture transparent to users.
|
||||||
|
- Add admin controls for deleting sensitive feedback, screenshots, or diagnostic data.
|
||||||
|
- Ensure permissions are enforced server-side, not only in the UI.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- Logged-in users can submit general feedback from a persistent UI entry point.
|
||||||
|
- Logged-in users can submit targeted feedback from the custom right-click context menu.
|
||||||
|
- Right-click context menu includes Back, Forward, Copy, Cut, Paste, Link to here, Hard reload, and Feedback.
|
||||||
|
- Feedback modal can target the whole page, clicked element, current feature/page, plugin, broad system area, or custom scope.
|
||||||
|
- Users can optionally attach screenshots by upload or clipboard paste.
|
||||||
|
- `/feedback` appears under the Community navbar section for logged-in users.
|
||||||
|
- `/feedback` shows general non-identifying feedback summaries and detailed feedback for the current user’s own submissions.
|
||||||
|
- Users see per-user notification badges for solved, needs-context, and not-worked-on feedback.
|
||||||
|
- Admins can review and manage feedback at `/admin/feedback`.
|
||||||
|
- Admins can reply, add work notes, request more context, change statuses, finalize and close, reopen closed feedback, and permanently delete feedback.
|
||||||
|
- Feedback data is preserved across updates and included in protected user data.
|
||||||
|
- AI reply feedback remains handled by the AI Improvement Center, with an added `wrong tool` classification.
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
250
scripts/verify-feedback-system.js
Normal file
250
scripts/verify-feedback-system.js
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const ejs = require("ejs");
|
||||||
|
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
|
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-feedback-test-"));
|
||||||
|
const serviceDir = path.join(sandbox, "src", "services");
|
||||||
|
|
||||||
|
fs.mkdirSync(serviceDir, { recursive: true });
|
||||||
|
for (const file of ["db.js", "feedback.js"]) {
|
||||||
|
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
let database = null;
|
||||||
|
try {
|
||||||
|
database = require(path.join(serviceDir, "db.js"));
|
||||||
|
database.migrate();
|
||||||
|
const feedback = require(path.join(serviceDir, "feedback.js"));
|
||||||
|
const db = database.db;
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
|
||||||
|
).run("user-1", "FeedbackUser", now, now);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
|
||||||
|
).run("admin-1", "AdminUser", now, now);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)"
|
||||||
|
).run("user-2", "SecondUser", now, now);
|
||||||
|
|
||||||
|
const entry = feedback.createFeedback({
|
||||||
|
summary: "Save button fails",
|
||||||
|
category: "broken_interaction",
|
||||||
|
severity: "broken",
|
||||||
|
scope_type: "element",
|
||||||
|
scope_label: "Save settings button",
|
||||||
|
description: "Clicking the save button does not apply the changes.",
|
||||||
|
steps_to_reproduce: "Open settings, change a field, click save.",
|
||||||
|
expected_behavior: "The settings should be saved.",
|
||||||
|
actual_behavior: "The page reloads without saving.",
|
||||||
|
current_url: "http://localhost/admin/settings",
|
||||||
|
page_title: "Settings",
|
||||||
|
target_metadata: {
|
||||||
|
selector: "#save-settings",
|
||||||
|
tag: "button",
|
||||||
|
text: "Save settings",
|
||||||
|
secret: "must not persist"
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
user_agent: "verification-agent",
|
||||||
|
viewport: "1200x800",
|
||||||
|
screenshot_mode: "target",
|
||||||
|
hidden: "must not persist"
|
||||||
|
}
|
||||||
|
}, { id: "user-1" }, {
|
||||||
|
screenshot: {
|
||||||
|
path: "feedback/screenshots/test-shot.png",
|
||||||
|
mime: "image/png",
|
||||||
|
size: 2048
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(entry.status, "new");
|
||||||
|
assert.equal(entry.scope_label, "Clicked element: Save settings");
|
||||||
|
assert.equal(entry.target_metadata.secret, 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.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, "I see this too on the same settings page.", { id: "user-2" });
|
||||||
|
const withComment = feedback.getFeedbackForSubmitter(entry.id, "user-1");
|
||||||
|
assert.equal(withComment.comments.length, 2);
|
||||||
|
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");
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
|
||||||
|
feedback.adminUpdateFeedback(entry.id, {
|
||||||
|
status: "needs_more_context",
|
||||||
|
category: "bug",
|
||||||
|
severity: "urgent",
|
||||||
|
admin_reply: "Can you add which browser this happened in?",
|
||||||
|
work_note: "Likely settings form regression.",
|
||||||
|
status_note: "Need browser detail."
|
||||||
|
}, { id: "admin-1" });
|
||||||
|
const notices = feedback.notificationSummary("user-1");
|
||||||
|
assert.equal(notices.needs_context, 1);
|
||||||
|
const adminEntry = feedback.getFeedbackForAdmin(entry.id);
|
||||||
|
assert(adminEntry.comments.some((comment) => comment.kind === "admin_reply"));
|
||||||
|
assert(adminEntry.comments.some((comment) => comment.kind === "work_note"));
|
||||||
|
const submitterEntry = feedback.getFeedbackForSubmitter(entry.id, "user-1");
|
||||||
|
assert.equal(submitterEntry.comments.some((comment) => comment.kind === "work_note"), false);
|
||||||
|
|
||||||
|
feedback.adminUpdateFeedback(entry.id, {
|
||||||
|
status: "closed",
|
||||||
|
category: "bug",
|
||||||
|
severity: "urgent",
|
||||||
|
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 adminView = path.join(root, "src", "web", "views", "admin-feedback.ejs");
|
||||||
|
const commonLocals = {
|
||||||
|
siteTitle: "Lumi Bot",
|
||||||
|
assetVersion: "verify",
|
||||||
|
theme: null,
|
||||||
|
botAvatar: null,
|
||||||
|
navSections: [],
|
||||||
|
user: { id: "user-1", username: "FeedbackUser" },
|
||||||
|
userAvatar: null,
|
||||||
|
userInitial: "F",
|
||||||
|
platformLogins: [],
|
||||||
|
platformLinks: [],
|
||||||
|
platforms: [],
|
||||||
|
flash: null,
|
||||||
|
softError: null,
|
||||||
|
feedbackNotifications: feedback.notificationSummary("user-1"),
|
||||||
|
feedbackOptions: feedback.feedbackOptions()
|
||||||
|
};
|
||||||
|
const userRendered = ejs.render(fs.readFileSync(feedbackView, "utf8"), {
|
||||||
|
...commonLocals,
|
||||||
|
title: "Feedback",
|
||||||
|
feedbackList: feedback.listPublicFeedback({ userId: "user-1" }),
|
||||||
|
myFeedback: feedback.listMyFeedback("user-1"),
|
||||||
|
selectedFeedback: feedback.getFeedbackForSubmitter(entry.id, "user-1")
|
||||||
|
}, { filename: feedbackView });
|
||||||
|
assert(userRendered.includes("My feedback"));
|
||||||
|
assert(userRendered.includes("Admin reply"));
|
||||||
|
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"), {
|
||||||
|
...commonLocals,
|
||||||
|
user: { id: "admin-1", username: "AdminUser", isAdmin: true },
|
||||||
|
userInitial: "A",
|
||||||
|
title: "Feedback review",
|
||||||
|
feedbackItems: feedback.listFeedbackForAdmin({}),
|
||||||
|
filters: { status: "", category: "", severity: "", scope: "", area: "", submitter: "", date_from: "", date_to: "", needs_action: "", sort: "last_activity" }
|
||||||
|
}, { filename: adminView });
|
||||||
|
assert(adminRendered.includes("Feedback queue"));
|
||||||
|
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.");
|
||||||
|
} finally {
|
||||||
|
database?.db.close();
|
||||||
|
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@ -149,6 +149,91 @@ function migrate() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at);
|
CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
submitter_id TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
scope_type TEXT NOT NULL,
|
||||||
|
scope_label TEXT,
|
||||||
|
target_metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
current_url TEXT,
|
||||||
|
page_title TEXT,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
steps_to_reproduce TEXT,
|
||||||
|
expected_behavior TEXT,
|
||||||
|
actual_behavior TEXT,
|
||||||
|
diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
screenshot_path TEXT,
|
||||||
|
screenshot_mime TEXT,
|
||||||
|
screenshot_size INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'new',
|
||||||
|
admin_reply TEXT,
|
||||||
|
assigned_admin_id TEXT,
|
||||||
|
linked_todo TEXT,
|
||||||
|
linked_issue TEXT,
|
||||||
|
linked_correction TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
last_activity_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_entries_submitter_idx ON feedback_entries (submitter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_entries_status_idx ON feedback_entries (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_entries_last_activity_idx ON feedback_entries (last_activity_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
feedback_id TEXT NOT NULL,
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
visible_to_submitter INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_comments_feedback_idx ON feedback_comments (feedback_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_status_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
feedback_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
actor_id TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_status_history_feedback_idx ON feedback_status_history (feedback_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_view_state (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
last_seen_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
@ -211,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
952
src/services/feedback.js
Normal file
952
src/services/feedback.js
Normal file
@ -0,0 +1,952 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const { db } = require("./db");
|
||||||
|
|
||||||
|
const FEEDBACK_CATEGORIES = Object.freeze([
|
||||||
|
"bug",
|
||||||
|
"confusing_wording",
|
||||||
|
"broken_interaction",
|
||||||
|
"visual_layout_issue",
|
||||||
|
"accessibility_issue",
|
||||||
|
"missing_feature",
|
||||||
|
"improvement_suggestion",
|
||||||
|
"performance_issue",
|
||||||
|
"permission_access_issue",
|
||||||
|
"unexpected_behavior",
|
||||||
|
"other"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FEEDBACK_SEVERITIES = Object.freeze([
|
||||||
|
"minor",
|
||||||
|
"confusing",
|
||||||
|
"broken",
|
||||||
|
"urgent",
|
||||||
|
"security_sensitive",
|
||||||
|
"suggestion"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FEEDBACK_SCOPE_TYPES = Object.freeze([
|
||||||
|
"page",
|
||||||
|
"element",
|
||||||
|
"feature",
|
||||||
|
"plugin",
|
||||||
|
"system",
|
||||||
|
"other"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FEEDBACK_STATUSES = Object.freeze([
|
||||||
|
"new",
|
||||||
|
"reviewed",
|
||||||
|
"accepted",
|
||||||
|
"planned",
|
||||||
|
"in_progress",
|
||||||
|
"fixed",
|
||||||
|
"solved",
|
||||||
|
"needs_more_context",
|
||||||
|
"duplicate",
|
||||||
|
"rejected",
|
||||||
|
"not_planned",
|
||||||
|
"wont_fix",
|
||||||
|
"closed",
|
||||||
|
"archived",
|
||||||
|
"deleted"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const USER_VISIBLE_STATUSES = new Set(FEEDBACK_STATUSES.filter((status) => status !== "deleted"));
|
||||||
|
const SOLVED_STATUSES = new Set(["fixed", "solved", "closed"]);
|
||||||
|
const NEEDS_CONTEXT_STATUSES = new Set(["needs_more_context"]);
|
||||||
|
const NOT_WORKING_STATUSES = new Set(["duplicate", "rejected", "not_planned", "wont_fix"]);
|
||||||
|
const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 };
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = Object.freeze({
|
||||||
|
bug: "Bug",
|
||||||
|
confusing_wording: "Confusing wording",
|
||||||
|
broken_interaction: "Broken interaction",
|
||||||
|
visual_layout_issue: "Visual/layout issue",
|
||||||
|
accessibility_issue: "Accessibility issue",
|
||||||
|
missing_feature: "Missing feature",
|
||||||
|
improvement_suggestion: "Improvement suggestion",
|
||||||
|
performance_issue: "Performance issue",
|
||||||
|
permission_access_issue: "Permission/access issue",
|
||||||
|
unexpected_behavior: "Unexpected behavior",
|
||||||
|
other: "Other"
|
||||||
|
});
|
||||||
|
|
||||||
|
const SEVERITY_LABELS = Object.freeze({
|
||||||
|
minor: "Minor",
|
||||||
|
confusing: "Confusing",
|
||||||
|
broken: "Broken",
|
||||||
|
urgent: "Urgent",
|
||||||
|
security_sensitive: "Security/sensitive",
|
||||||
|
suggestion: "Suggestion"
|
||||||
|
});
|
||||||
|
|
||||||
|
const SCOPE_LABELS = Object.freeze({
|
||||||
|
page: "Whole page",
|
||||||
|
element: "Clicked element",
|
||||||
|
feature: "Current feature/page",
|
||||||
|
plugin: "Plugin",
|
||||||
|
system: "System area",
|
||||||
|
other: "Other"
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_LABELS = Object.freeze({
|
||||||
|
new: "New",
|
||||||
|
reviewed: "Reviewed",
|
||||||
|
accepted: "Accepted",
|
||||||
|
planned: "Planned",
|
||||||
|
in_progress: "In progress",
|
||||||
|
fixed: "Fixed",
|
||||||
|
solved: "Solved",
|
||||||
|
needs_more_context: "Needs more context",
|
||||||
|
duplicate: "Duplicate",
|
||||||
|
rejected: "Rejected",
|
||||||
|
not_planned: "Not planned",
|
||||||
|
wont_fix: "Won't fix",
|
||||||
|
closed: "Closed",
|
||||||
|
archived: "Archived",
|
||||||
|
deleted: "Deleted"
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_HELP = Object.freeze({
|
||||||
|
new: "Submitted and waiting for review.",
|
||||||
|
reviewed: "Seen by an administrator.",
|
||||||
|
accepted: "Accepted as valid feedback.",
|
||||||
|
planned: "Planned for a future pass.",
|
||||||
|
in_progress: "Being worked on.",
|
||||||
|
fixed: "A fix has been made.",
|
||||||
|
solved: "Resolved and no longer needs action.",
|
||||||
|
needs_more_context: "The submitter needs to add more detail.",
|
||||||
|
duplicate: "Covered by another feedback item.",
|
||||||
|
rejected: "Rejected after review.",
|
||||||
|
not_planned: "Not planned for implementation.",
|
||||||
|
wont_fix: "Reviewed, but will not be changed.",
|
||||||
|
closed: "Finalized and closed by an administrator.",
|
||||||
|
archived: "Archived for record keeping.",
|
||||||
|
deleted: "Deleted by an administrator."
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFeedback(input, actor, options = {}) {
|
||||||
|
if (!actor?.id) {
|
||||||
|
throw new Error("Feedback requires a logged-in user.");
|
||||||
|
}
|
||||||
|
enforceRateLimit(actor.id);
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = normalizeFeedbackInput(input);
|
||||||
|
const screenshot = normalizeScreenshot(options.screenshot);
|
||||||
|
const attachments = normalizeAttachments(options.attachments);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_entries " +
|
||||||
|
"(id, submitter_id, summary, category, severity, scope_type, scope_label, target_metadata_json, current_url, page_title, description, steps_to_reproduce, expected_behavior, actual_behavior, diagnostics_json, screenshot_path, screenshot_mime, screenshot_size, status, created_at, updated_at, last_activity_at) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)"
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
actor.id,
|
||||||
|
entry.summary,
|
||||||
|
entry.category,
|
||||||
|
entry.severity,
|
||||||
|
entry.scope_type,
|
||||||
|
entry.scope_label,
|
||||||
|
JSON.stringify(entry.target_metadata),
|
||||||
|
entry.current_url,
|
||||||
|
entry.page_title,
|
||||||
|
entry.description,
|
||||||
|
entry.steps_to_reproduce,
|
||||||
|
entry.expected_behavior,
|
||||||
|
entry.actual_behavior,
|
||||||
|
JSON.stringify(entry.diagnostics),
|
||||||
|
screenshot.path,
|
||||||
|
screenshot.mime,
|
||||||
|
screenshot.size,
|
||||||
|
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);
|
||||||
|
})();
|
||||||
|
return getFeedbackForAdmin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPublicFeedback({ userId, limit = 100 } = {}) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, submitter_id, summary, category, severity, scope_type, scope_label, status, created_at, updated_at, last_activity_at " +
|
||||||
|
"FROM feedback_entries WHERE deleted_at IS NULL AND status != 'deleted' " +
|
||||||
|
"ORDER BY last_activity_at DESC LIMIT ?"
|
||||||
|
)
|
||||||
|
.all(limit);
|
||||||
|
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),
|
||||||
|
match_score: Math.round(score * 100) / 100,
|
||||||
|
submitter_id: undefined
|
||||||
|
}));
|
||||||
|
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) {
|
||||||
|
if (!userId) return [];
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM feedback_entries WHERE submitter_id = ? AND deleted_at IS NULL AND status != 'deleted' ORDER BY last_activity_at DESC"
|
||||||
|
)
|
||||||
|
.all(userId)
|
||||||
|
.map((row) => hydrateFeedback(row, { admin: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeedbackForSubmitter(id, userId) {
|
||||||
|
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
|
||||||
|
if (!row || row.deleted_at || row.status === "deleted" || row.submitter_id !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hydrateFeedback(row, { admin: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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) {
|
||||||
|
const row = db.prepare("SELECT * FROM feedback_entries WHERE id = ?").get(id);
|
||||||
|
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 = {}) {
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
if (filters.status && FEEDBACK_STATUSES.includes(filters.status)) {
|
||||||
|
where.push("feedback_entries.status = ?");
|
||||||
|
params.push(filters.status);
|
||||||
|
} else {
|
||||||
|
where.push("feedback_entries.status != 'deleted'");
|
||||||
|
where.push("feedback_entries.deleted_at IS NULL");
|
||||||
|
}
|
||||||
|
if (filters.category && FEEDBACK_CATEGORIES.includes(filters.category)) {
|
||||||
|
where.push("feedback_entries.category = ?");
|
||||||
|
params.push(filters.category);
|
||||||
|
}
|
||||||
|
if (filters.severity && FEEDBACK_SEVERITIES.includes(filters.severity)) {
|
||||||
|
where.push("feedback_entries.severity = ?");
|
||||||
|
params.push(filters.severity);
|
||||||
|
}
|
||||||
|
if (filters.scope && FEEDBACK_SCOPE_TYPES.includes(filters.scope)) {
|
||||||
|
where.push("feedback_entries.scope_type = ?");
|
||||||
|
params.push(filters.scope);
|
||||||
|
}
|
||||||
|
if (filters.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) {
|
||||||
|
where.push(
|
||||||
|
"(feedback_entries.submitter_id = ? OR lower(user_profiles.internal_username) LIKE lower(?))"
|
||||||
|
);
|
||||||
|
params.push(filters.submitter, `%${filters.submitter}%`);
|
||||||
|
}
|
||||||
|
if (filters.needs_action === "1") {
|
||||||
|
where.push("feedback_entries.status IN ('new', 'needs_more_context')");
|
||||||
|
}
|
||||||
|
const order = {
|
||||||
|
oldest: "feedback_entries.created_at ASC",
|
||||||
|
severity: severityOrderSql(),
|
||||||
|
status: "feedback_entries.status ASC, feedback_entries.last_activity_at DESC",
|
||||||
|
last_activity: "feedback_entries.last_activity_at DESC",
|
||||||
|
newest: "feedback_entries.created_at DESC"
|
||||||
|
}[filters.sort || "last_activity"];
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT feedback_entries.*, user_profiles.internal_username AS submitter_name " +
|
||||||
|
"FROM feedback_entries LEFT JOIN user_profiles ON user_profiles.id = feedback_entries.submitter_id " +
|
||||||
|
`WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT 250`
|
||||||
|
)
|
||||||
|
.all(...params);
|
||||||
|
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) {
|
||||||
|
const row = db.prepare("SELECT id, submitter_id, status, deleted_at FROM feedback_entries WHERE id = ?").get(id);
|
||||||
|
if (!row || row.deleted_at || row.status === "deleted" || !actor?.id) {
|
||||||
|
throw new Error("Feedback item was not found.");
|
||||||
|
}
|
||||||
|
const comment = cleanText(body, 4000);
|
||||||
|
if (comment.length < 3) {
|
||||||
|
throw new Error("Comment is too short.");
|
||||||
|
}
|
||||||
|
const kind = row.submitter_id === actor.id ? "submitter_comment" : "public_comment";
|
||||||
|
const now = Date.now();
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, ?, ?, 1, ?)"
|
||||||
|
).run(id, actor.id, kind, comment, 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) {
|
||||||
|
const current = getFeedbackForAdmin(id);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Feedback item was not found.");
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const nextCategory = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : current.category;
|
||||||
|
const nextSeverity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : current.severity;
|
||||||
|
const nextStatus = FEEDBACK_STATUSES.includes(input.status) ? input.status : current.status;
|
||||||
|
const adminReply = cleanText(input.admin_reply, 6000);
|
||||||
|
const workNote = cleanText(input.work_note, 6000);
|
||||||
|
const statusNote = cleanText(input.status_note, 1000);
|
||||||
|
const linkedIssue = cleanText(input.linked_issue, 1000);
|
||||||
|
const linkedCorrection = cleanText(input.linked_correction, 1000);
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE feedback_entries SET category = ?, severity = ?, status = ?, admin_reply = ?, linked_issue = ?, linked_correction = ?, updated_at = ?, last_activity_at = ?, deleted_at = ? WHERE id = ?"
|
||||||
|
).run(
|
||||||
|
nextCategory,
|
||||||
|
nextSeverity,
|
||||||
|
nextStatus,
|
||||||
|
adminReply || null,
|
||||||
|
linkedIssue || null,
|
||||||
|
linkedCorrection || null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
nextStatus === "deleted" ? now : null,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (nextStatus !== current.status) {
|
||||||
|
addStatusHistory(id, nextStatus, actor.id, statusNote, now);
|
||||||
|
}
|
||||||
|
if (adminReply && adminReply !== (current.admin_reply || "")) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'admin_reply', ?, 1, ?)"
|
||||||
|
).run(id, actor.id, adminReply, now);
|
||||||
|
}
|
||||||
|
if (workNote) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
|
||||||
|
).run(id, actor.id, workNote, now);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return getFeedbackForAdmin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFeedback(id, options = {}) {
|
||||||
|
const current = db.prepare("SELECT id, screenshot_path FROM feedback_entries WHERE id = ?").get(id);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Feedback item was not found.");
|
||||||
|
}
|
||||||
|
const attachments = attachmentsFor(id);
|
||||||
|
db.transaction(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (typeof options.deleteAttachment === "function") {
|
||||||
|
attachments.forEach((attachment) => options.deleteAttachment(attachment.storage_path));
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupFeedback(id, input = {}, actor, options = {}) {
|
||||||
|
const current = getFeedbackForAdmin(id);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Feedback item was not found.");
|
||||||
|
}
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE feedback_entries SET ${sets.join(", ")}, updated_at = ?, last_activity_at = ? WHERE id = ?`
|
||||||
|
).run(now, now, id);
|
||||||
|
if (clearAttachments) {
|
||||||
|
db.prepare("DELETE FROM feedback_attachments WHERE feedback_id = ?").run(id);
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_comments (feedback_id, actor_id, kind, body, visible_to_submitter, created_at) VALUES (?, ?, 'work_note', ?, 0, ?)"
|
||||||
|
).run(id, actor?.id || null, `Cleaned sensitive feedback data: ${changes.join(", ")}.`, now);
|
||||||
|
})();
|
||||||
|
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) {
|
||||||
|
if (!userId) return;
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_view_state (user_id, last_seen_at) VALUES (?, ?) " +
|
||||||
|
"ON CONFLICT(user_id) DO UPDATE SET last_seen_at = excluded.last_seen_at"
|
||||||
|
).run(userId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationSummary(userId) {
|
||||||
|
if (!userId) {
|
||||||
|
return { solved: 0, needs_context: 0, not_worked: 0, total: 0 };
|
||||||
|
}
|
||||||
|
const viewed = db
|
||||||
|
.prepare("SELECT last_seen_at FROM feedback_view_state WHERE user_id = ?")
|
||||||
|
.get(userId);
|
||||||
|
const since = viewed?.last_seen_at || 0;
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT status, updated_at FROM feedback_entries WHERE submitter_id = ? AND updated_at > ? AND deleted_at IS NULL AND status != 'deleted'"
|
||||||
|
)
|
||||||
|
.all(userId, since);
|
||||||
|
const summary = { solved: 0, needs_context: 0, not_worked: 0, total: 0 };
|
||||||
|
for (const row of rows) {
|
||||||
|
if (SOLVED_STATUSES.has(row.status)) summary.solved += 1;
|
||||||
|
if (NEEDS_CONTEXT_STATUSES.has(row.status)) summary.needs_context += 1;
|
||||||
|
if (NOT_WORKING_STATUSES.has(row.status)) summary.not_worked += 1;
|
||||||
|
}
|
||||||
|
summary.total = summary.solved + summary.needs_context + summary.not_worked;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackOptions() {
|
||||||
|
return {
|
||||||
|
categories: FEEDBACK_CATEGORIES.map((value) => ({ value, label: CATEGORY_LABELS[value] })),
|
||||||
|
severities: FEEDBACK_SEVERITIES.map((value) => ({ value, label: SEVERITY_LABELS[value] })),
|
||||||
|
scopes: FEEDBACK_SCOPE_TYPES.map((value) => ({ value, label: SCOPE_LABELS[value] })),
|
||||||
|
statuses: FEEDBACK_STATUSES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: STATUS_LABELS[value],
|
||||||
|
help: STATUS_HELP[value]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFeedbackInput(input = {}) {
|
||||||
|
const summary = cleanText(input.summary, 140);
|
||||||
|
const description = cleanText(input.description, 6000);
|
||||||
|
if (summary.length < 6) {
|
||||||
|
throw new Error("Feedback needs a short summary with at least 6 characters.");
|
||||||
|
}
|
||||||
|
if (description.length < 10) {
|
||||||
|
throw new Error("Feedback needs a description with at least 10 characters.");
|
||||||
|
}
|
||||||
|
const category = FEEDBACK_CATEGORIES.includes(input.category) ? input.category : "other";
|
||||||
|
const severity = FEEDBACK_SEVERITIES.includes(input.severity) ? input.severity : "minor";
|
||||||
|
const scopeType = FEEDBACK_SCOPE_TYPES.includes(input.scope_type) ? input.scope_type : "page";
|
||||||
|
const targetMetadata = sanitizeJsonObject(input.target_metadata, sanitizeTargetMetadata);
|
||||||
|
const pageTitle = cleanText(input.page_title, 240);
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
category,
|
||||||
|
severity,
|
||||||
|
scope_type: scopeType,
|
||||||
|
scope_label: deriveScopeLabel(scopeType, targetMetadata, pageTitle, input.current_url),
|
||||||
|
target_metadata: targetMetadata,
|
||||||
|
current_url: cleanUrl(input.current_url),
|
||||||
|
page_title: pageTitle,
|
||||||
|
description,
|
||||||
|
steps_to_reproduce: cleanText(input.steps_to_reproduce, 4000),
|
||||||
|
expected_behavior: cleanText(input.expected_behavior, 4000),
|
||||||
|
actual_behavior: cleanText(input.actual_behavior, 4000),
|
||||||
|
diagnostics: sanitizeJsonObject(input.diagnostics, sanitizeDiagnostics)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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) {
|
||||||
|
const cutoff = Date.now() - RATE_LIMIT.windowMs;
|
||||||
|
const count = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS count FROM feedback_entries WHERE submitter_id = ? AND created_at >= ?"
|
||||||
|
)
|
||||||
|
.get(userId, cutoff).count;
|
||||||
|
if (count >= RATE_LIMIT.max) {
|
||||||
|
throw new Error("Too many feedback reports were submitted recently. Please wait a few minutes.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateFeedback(row, { admin }) {
|
||||||
|
const parsed = {
|
||||||
|
...decorateLabels(row),
|
||||||
|
target_metadata: parseJson(row.target_metadata_json, {}),
|
||||||
|
diagnostics: admin ? parseJson(row.diagnostics_json, {}) : {},
|
||||||
|
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),
|
||||||
|
history: statusHistoryFor(row.id)
|
||||||
|
};
|
||||||
|
if (!admin) {
|
||||||
|
delete parsed.submitter_id;
|
||||||
|
delete parsed.assigned_admin_id;
|
||||||
|
delete parsed.diagnostics_json;
|
||||||
|
parsed.comments = parsed.comments.filter((comment) => comment.visible_to_submitter);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT feedback_comments.*, user_profiles.internal_username AS actor_name " +
|
||||||
|
"FROM feedback_comments LEFT JOIN user_profiles ON user_profiles.id = feedback_comments.actor_id " +
|
||||||
|
"WHERE feedback_id = ? ORDER BY created_at ASC"
|
||||||
|
)
|
||||||
|
.all(feedbackId)
|
||||||
|
.filter((row) => admin || row.visible_to_submitter)
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
visible_to_submitter: Boolean(row.visible_to_submitter),
|
||||||
|
kind_label: commentKindLabel(row.kind)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusHistoryFor(feedbackId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT feedback_status_history.*, user_profiles.internal_username AS actor_name " +
|
||||||
|
"FROM feedback_status_history LEFT JOIN user_profiles ON user_profiles.id = feedback_status_history.actor_id " +
|
||||||
|
"WHERE feedback_id = ? ORDER BY created_at ASC"
|
||||||
|
)
|
||||||
|
.all(feedbackId)
|
||||||
|
.map((row) => decorateLabels(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStatusHistory(feedbackId, status, actorId, note, now) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO feedback_status_history (feedback_id, status, actor_id, note, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
).run(feedbackId, status, actorId || null, cleanText(note, 1000), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchFeedback(id, now = Date.now()) {
|
||||||
|
db.prepare("UPDATE feedback_entries SET updated_at = ?, last_activity_at = ? WHERE id = ?").run(now, now, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateLabels(row) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
category_label: CATEGORY_LABELS[row.category] || row.category,
|
||||||
|
severity_label: SEVERITY_LABELS[row.severity] || row.severity,
|
||||||
|
scope_label_display: row.scope_label || SCOPE_LABELS[row.scope_type] || row.scope_type,
|
||||||
|
scope_type_label: SCOPE_LABELS[row.scope_type] || row.scope_type,
|
||||||
|
status_label: STATUS_LABELS[row.status] || row.status,
|
||||||
|
status_help: STATUS_HELP[row.status] || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTargetMetadata(value) {
|
||||||
|
return {
|
||||||
|
selector: cleanText(value.selector, 500),
|
||||||
|
tag: cleanText(value.tag, 40),
|
||||||
|
text: cleanText(value.text, 300),
|
||||||
|
aria_label: cleanText(value.aria_label, 200),
|
||||||
|
title: cleanText(value.title, 200),
|
||||||
|
role: cleanText(value.role, 80),
|
||||||
|
label: cleanText(value.label, 200),
|
||||||
|
heading: cleanText(value.heading, 200),
|
||||||
|
page_url: cleanUrl(value.page_url),
|
||||||
|
page_title: cleanText(value.page_title, 240),
|
||||||
|
viewport: cleanText(value.viewport, 80)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDiagnostics(value) {
|
||||||
|
return {
|
||||||
|
user_agent: cleanText(value.user_agent, 500),
|
||||||
|
viewport: cleanText(value.viewport, 80),
|
||||||
|
language: cleanText(value.language, 80),
|
||||||
|
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) {
|
||||||
|
const object = typeof value === "object" && value && !Array.isArray(value) ? value : {};
|
||||||
|
return removeEmptyFields(sanitizer(object));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmptyFields(value) {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== "" && entry !== null && entry !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value || "");
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value, max) {
|
||||||
|
return String(value || "").replace(/\s+\n/g, "\n").trim().slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUrl(value) {
|
||||||
|
const raw = cleanText(value, 1000);
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
const url = new URL(raw, "http://localhost");
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) return "";
|
||||||
|
return raw;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentKindLabel(kind) {
|
||||||
|
return {
|
||||||
|
submitter_comment: "Submitter comment",
|
||||||
|
public_comment: "Community comment",
|
||||||
|
admin_reply: "Admin reply",
|
||||||
|
work_note: "Private work note"
|
||||||
|
}[kind] || kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityOrderSql() {
|
||||||
|
return "CASE feedback_entries.severity " +
|
||||||
|
"WHEN 'security_sensitive' THEN 0 " +
|
||||||
|
"WHEN 'urgent' THEN 1 " +
|
||||||
|
"WHEN 'broken' THEN 2 " +
|
||||||
|
"WHEN 'performance_issue' THEN 3 " +
|
||||||
|
"WHEN 'confusing' THEN 4 " +
|
||||||
|
"WHEN 'minor' THEN 5 " +
|
||||||
|
"ELSE 6 END, feedback_entries.last_activity_at DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FEEDBACK_CATEGORIES,
|
||||||
|
FEEDBACK_SCOPE_TYPES,
|
||||||
|
FEEDBACK_SEVERITIES,
|
||||||
|
FEEDBACK_STATUSES,
|
||||||
|
USER_VISIBLE_STATUSES,
|
||||||
|
adminUpdateFeedback,
|
||||||
|
cleanupFeedback,
|
||||||
|
deleteFeedback,
|
||||||
|
createFeedback,
|
||||||
|
findSimilarFeedback,
|
||||||
|
feedbackOptions,
|
||||||
|
getFeedbackForAdmin,
|
||||||
|
getFeedbackAttachment,
|
||||||
|
getFeedbackForSubmitter,
|
||||||
|
getFeedbackForViewer,
|
||||||
|
listFeedbackForAdmin,
|
||||||
|
listMyFeedback,
|
||||||
|
listPublicFeedback,
|
||||||
|
markFeedbackViewed,
|
||||||
|
notificationSummary,
|
||||||
|
supportFeedback,
|
||||||
|
addSubmitterComment
|
||||||
|
};
|
||||||
@ -945,4 +945,785 @@
|
|||||||
markCopyResult(false);
|
markCopyResult(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const feedbackModal = document.querySelector("[data-feedback-modal]");
|
||||||
|
const feedbackForm = document.querySelector("[data-feedback-form]");
|
||||||
|
const contextMenu = document.querySelector("[data-lumi-context-menu]");
|
||||||
|
let contextTarget = null;
|
||||||
|
let highlightedFeedbackTarget = null;
|
||||||
|
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 = {}) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value || "");
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedbackOptions = parseJson(feedbackModal?.dataset.feedbackOptions, {});
|
||||||
|
|
||||||
|
const setSelectOptions = (select, entries, preferred) => {
|
||||||
|
if (!select || !Array.isArray(entries)) return;
|
||||||
|
select.replaceChildren();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = entry.value;
|
||||||
|
option.textContent = entry.label;
|
||||||
|
if (entry.value === preferred) option.selected = true;
|
||||||
|
select.append(option);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (feedbackForm) {
|
||||||
|
setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug");
|
||||||
|
setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor");
|
||||||
|
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, "page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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 = [
|
||||||
|
"input[type='password']",
|
||||||
|
"input[type='hidden']",
|
||||||
|
"input[name*='token' i]",
|
||||||
|
"input[name*='secret' i]",
|
||||||
|
"input[name*='password' i]",
|
||||||
|
"textarea[name*='token' i]",
|
||||||
|
"textarea[name*='secret' i]",
|
||||||
|
"textarea[name*='password' i]"
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
const safeText = (value, max = 300) => String(value || "").replace(/\s+/g, " ").trim().slice(0, max);
|
||||||
|
|
||||||
|
const cssPath = (element) => {
|
||||||
|
if (!element || element === document.body || element === document.documentElement) return "body";
|
||||||
|
if (element.id) return `#${selectorEscape(element.id)}`;
|
||||||
|
const parts = [];
|
||||||
|
let node = element;
|
||||||
|
while (node && node.nodeType === 1 && node !== document.body && parts.length < 5) {
|
||||||
|
let part = node.tagName.toLowerCase();
|
||||||
|
if (node.classList.length) {
|
||||||
|
part += `.${Array.from(node.classList).slice(0, 2).map(selectorEscape).join(".")}`;
|
||||||
|
}
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const siblings = Array.from(parent.children).filter((child) => child.tagName === node.tagName);
|
||||||
|
if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
return parts.join(" > ") || "body";
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearestText = (element, selector) => {
|
||||||
|
const match = element?.closest?.(selector);
|
||||||
|
return safeText(match?.innerText || match?.textContent || "", 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeElementText = (element) => {
|
||||||
|
if (!element || element.matches?.(sensitiveSelector)) return "";
|
||||||
|
if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return safeText(element.innerText || element.textContent || "", 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedbackTargetMetadata = (element) => {
|
||||||
|
const target = element?.closest?.("button, a, input, textarea, select, label, summary, [role], .card, section, main") || element || document.body;
|
||||||
|
return {
|
||||||
|
selector: cssPath(target),
|
||||||
|
tag: target?.tagName?.toLowerCase?.() || "",
|
||||||
|
text: safeElementText(target),
|
||||||
|
aria_label: safeText(target?.getAttribute?.("aria-label"), 200),
|
||||||
|
title: safeText(target?.getAttribute?.("title"), 200),
|
||||||
|
role: safeText(target?.getAttribute?.("role"), 80),
|
||||||
|
label: nearestText(target, "label"),
|
||||||
|
heading: nearestText(target, "section, .card"),
|
||||||
|
page_url: window.location.href,
|
||||||
|
page_title: document.title,
|
||||||
|
viewport: `${window.innerWidth}x${window.innerHeight}`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPageLabel = () => {
|
||||||
|
const h1 = document.querySelector("h1");
|
||||||
|
return safeText(h1?.innerText || document.title, 180);
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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 = () => {
|
||||||
|
highlightedFeedbackTarget?.classList?.remove("feedback-target-highlight");
|
||||||
|
highlightedFeedbackTarget = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFeedbackModal = ({ scope = "page", target = null } = {}) => {
|
||||||
|
if (!feedbackModal || !feedbackForm) return;
|
||||||
|
hideContextMenu();
|
||||||
|
clearFeedbackHighlight();
|
||||||
|
feedbackForm.reset();
|
||||||
|
clearAttachedScreenshot();
|
||||||
|
setSelectOptions(feedbackForm.querySelector("[data-feedback-category]"), feedbackOptions.categories, "bug");
|
||||||
|
setSelectOptions(feedbackForm.querySelector("[data-feedback-severity]"), feedbackOptions.severities, "minor");
|
||||||
|
setSelectOptions(feedbackForm.querySelector("[data-feedback-scope-type]"), feedbackOptions.scopes, scope);
|
||||||
|
const metadata = target ? feedbackTargetMetadata(target) : {};
|
||||||
|
activeFeedbackTarget = target;
|
||||||
|
activeFeedbackMetadata = metadata;
|
||||||
|
const targetInput = feedbackForm.querySelector("[data-feedback-target-metadata]");
|
||||||
|
const urlInput = feedbackForm.querySelector("[data-feedback-current-url]");
|
||||||
|
const titleInput = feedbackForm.querySelector("[data-feedback-page-title]");
|
||||||
|
const diagnosticsInput = feedbackForm.querySelector("[data-feedback-diagnostics]");
|
||||||
|
const status = feedbackForm.querySelector("[data-feedback-status]");
|
||||||
|
if (targetInput) targetInput.value = JSON.stringify(metadata);
|
||||||
|
if (urlInput) urlInput.value = window.location.href;
|
||||||
|
if (titleInput) titleInput.value = document.title;
|
||||||
|
if (diagnosticsInput) diagnosticsInput.value = "{}";
|
||||||
|
updateGeneratedScopeLabel();
|
||||||
|
clearSimilarFeedback();
|
||||||
|
if (status) status.textContent = "";
|
||||||
|
if (target && scope === "element") {
|
||||||
|
highlightedFeedbackTarget = target;
|
||||||
|
highlightedFeedbackTarget.classList.add("feedback-target-highlight");
|
||||||
|
}
|
||||||
|
feedbackModal.classList.add("is-open");
|
||||||
|
feedbackModal.setAttribute("aria-hidden", "false");
|
||||||
|
feedbackForm.querySelector("input[name='summary']")?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFeedbackModal = () => {
|
||||||
|
if (!feedbackModal) return;
|
||||||
|
feedbackModal.classList.remove("is-open");
|
||||||
|
feedbackModal.setAttribute("aria-hidden", "true");
|
||||||
|
clearFeedbackHighlight();
|
||||||
|
activeFeedbackTarget = null;
|
||||||
|
activeFeedbackMetadata = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const openButton = event.target.closest("[data-feedback-open]");
|
||||||
|
if (openButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
openFeedbackModal({ scope: openButton.dataset.feedbackScope || "page" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest("[data-feedback-close]") || event.target === feedbackModal) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeFeedbackModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
feedbackForm?.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) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const submitter = feedbackForm.querySelector("button[type='submit']");
|
||||||
|
const status = feedbackForm.querySelector("[data-feedback-status]");
|
||||||
|
const includeBrowser = feedbackForm.querySelector("[data-feedback-include-browser]")?.checked;
|
||||||
|
const includeDom = feedbackForm.querySelector("[data-feedback-include-dom]")?.checked;
|
||||||
|
const formData = new FormData(feedbackForm);
|
||||||
|
formData.set("target_metadata", JSON.stringify(parseJson(formData.get("target_metadata"), {})));
|
||||||
|
formData.set("diagnostics", JSON.stringify({
|
||||||
|
...(includeBrowser
|
||||||
|
? {
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
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;
|
||||||
|
if (status) status.textContent = "Submitting feedback...";
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || "Feedback could not be submitted.");
|
||||||
|
if (status) status.textContent = result.message || "Feedback submitted.";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
closeFeedbackModal();
|
||||||
|
window.location.href = `/feedback?id=${encodeURIComponent(result.id)}`;
|
||||||
|
}, 700);
|
||||||
|
} catch (error) {
|
||||||
|
if (status) status.textContent = error.message;
|
||||||
|
submitter.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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']");
|
||||||
|
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 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;
|
||||||
|
const active = document.activeElement;
|
||||||
|
const pasteTarget = editableElementForTarget(active);
|
||||||
|
const selectedText = selectedTextForTarget(target).trim();
|
||||||
|
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 = async (event) => {
|
||||||
|
if (!contextMenu || event.shiftKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
contextTarget = event.target;
|
||||||
|
contextMenu.hidden = false;
|
||||||
|
await updateContextMenuItems(contextTarget);
|
||||||
|
const width = contextMenu.offsetWidth || 180;
|
||||||
|
const height = contextMenu.offsetHeight || 260;
|
||||||
|
contextMenu.style.left = `${Math.min(event.clientX, window.innerWidth - width - 8)}px`;
|
||||||
|
contextMenu.style.top = `${Math.min(event.clientY, window.innerHeight - height - 8)}px`;
|
||||||
|
contextMenu.querySelector("button:not(.is-disabled)")?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
function hideContextMenu() {
|
||||||
|
if (!contextMenu) return;
|
||||||
|
contextMenu.hidden = true;
|
||||||
|
hideContextTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkForTarget = (target) => {
|
||||||
|
const element = target?.closest?.("[id]") || document.querySelector("h1[id]") || null;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (element?.id) url.hash = element.id;
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyImage = async (image) => {
|
||||||
|
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 {
|
||||||
|
if (action === "back") window.history.back();
|
||||||
|
if (action === "forward") {
|
||||||
|
if (window.navigation?.canGoForward) window.history.forward();
|
||||||
|
}
|
||||||
|
if (action === "copy") {
|
||||||
|
const selectedText = selectedTextForTarget(contextTarget);
|
||||||
|
if (selectedText) await copyText(selectedText);
|
||||||
|
else await copyImage(contextImageForTarget(contextTarget));
|
||||||
|
}
|
||||||
|
if (action === "cut" && editable) document.execCommand("cut");
|
||||||
|
if (action === "paste" && editable) {
|
||||||
|
const text = contextClipboardText || await readClipboardText();
|
||||||
|
insertTextIntoEditable(editable, text);
|
||||||
|
}
|
||||||
|
if (action === "link") await copyText(linkForTarget(contextTarget));
|
||||||
|
if (action === "reload") window.location.reload(true);
|
||||||
|
if (action === "feedback") openFeedbackModal({ scope: "element", target: contextTarget });
|
||||||
|
} catch {
|
||||||
|
if (action === "paste" && editable) document.execCommand("paste");
|
||||||
|
} finally {
|
||||||
|
if (action !== "feedback") hideContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("contextmenu", showContextMenu);
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-context-action]");
|
||||||
|
if (!button || !contextMenu?.contains(button)) {
|
||||||
|
hideContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
runContextAction(button.dataset.contextAction, 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) => {
|
||||||
|
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") {
|
||||||
|
hideContextMenu();
|
||||||
|
closeFeedbackModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1255,6 +1255,382 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedback-floating-button {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 80;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--sea);
|
||||||
|
color: var(--ink-inverse, #081114);
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-floating-badge {
|
||||||
|
min-width: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--rose);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-submit-status {
|
||||||
|
min-height: 20px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
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-badge-row,
|
||||||
|
.feedback-two-col,
|
||||||
|
.feedback-admin-filters,
|
||||||
|
.feedback-detail-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-badge-row {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-pill.solved {
|
||||||
|
border-color: rgba(48, 170, 112, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-pill.needs-context {
|
||||||
|
border-color: rgba(236, 91, 91, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-pill.not-worked {
|
||||||
|
border-color: rgba(148, 163, 184, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-list,
|
||||||
|
.feedback-admin-list,
|
||||||
|
.feedback-comments {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-row,
|
||||||
|
.feedback-admin-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-row h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-row.is-selected {
|
||||||
|
outline: 2px solid var(--sea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-detail-grid {
|
||||||
|
align-items: stretch;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-detail-grid > div {
|
||||||
|
flex: 1 1 170px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-detail-grid span {
|
||||||
|
display: block;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-copy-block,
|
||||||
|
.feedback-comment {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-copy-block p,
|
||||||
|
.feedback-comment p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-two-col > * {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-comment-admin_reply {
|
||||||
|
border-left: 4px solid var(--sea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-comment-submit_comment,
|
||||||
|
.feedback-comment-submitter_comment {
|
||||||
|
border-left: 4px solid var(--sun);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-comment-work_note {
|
||||||
|
border-left: 4px solid var(--ink-soft);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-fixed,
|
||||||
|
.feedback-status-solved,
|
||||||
|
.feedback-status-closed {
|
||||||
|
border-color: rgba(48, 170, 112, 0.45);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-needs_more_context {
|
||||||
|
border-color: rgba(236, 91, 91, 0.55);
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-status-duplicate,
|
||||||
|
.feedback-status-rejected,
|
||||||
|
.feedback-status-not_planned,
|
||||||
|
.feedback-status-wont_fix {
|
||||||
|
border-color: rgba(148, 163, 184, 0.55);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-item summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-item summary small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-metadata {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-metadata pre {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 260px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-history {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-target-highlight {
|
||||||
|
outline: 3px solid var(--sea) !important;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 120;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-context-menu button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-context-menu button:hover,
|
||||||
|
.lumi-context-menu button:focus {
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
@ -1518,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,6 +110,25 @@ const {
|
|||||||
publishWebEvent,
|
publishWebEvent,
|
||||||
subscribeWebEvents
|
subscribeWebEvents
|
||||||
} = require("../services/web-events");
|
} = require("../services/web-events");
|
||||||
|
const {
|
||||||
|
addSubmitterComment,
|
||||||
|
adminUpdateFeedback,
|
||||||
|
cleanupFeedback,
|
||||||
|
createFeedback,
|
||||||
|
deleteFeedback,
|
||||||
|
findSimilarFeedback,
|
||||||
|
feedbackOptions,
|
||||||
|
getFeedbackForSubmitter,
|
||||||
|
getFeedbackForAdmin,
|
||||||
|
getFeedbackAttachment,
|
||||||
|
getFeedbackForViewer,
|
||||||
|
listFeedbackForAdmin,
|
||||||
|
listMyFeedback,
|
||||||
|
listPublicFeedback,
|
||||||
|
markFeedbackViewed,
|
||||||
|
notificationSummary,
|
||||||
|
supportFeedback
|
||||||
|
} = require("../services/feedback");
|
||||||
|
|
||||||
function ensureSessionSecret() {
|
function ensureSessionSecret() {
|
||||||
let secret = getSetting("session_secret");
|
let secret = getSetting("session_secret");
|
||||||
@ -1670,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();
|
||||||
}
|
}
|
||||||
@ -2475,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) {
|
||||||
@ -2509,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 = [];
|
||||||
@ -2566,6 +2678,66 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
app.get("/api/feedback/similar", requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
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({
|
||||||
|
ok: true,
|
||||||
|
id: entry.id,
|
||||||
|
message: "Feedback submitted. You can review it from the Feedback page."
|
||||||
|
});
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.get("/api/feedback/notifications", requireAuth, (req, res) => {
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
res.json(notificationSummary(req.session.user.id));
|
||||||
|
});
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.method !== "POST" || !isDestructivePath(req.path)) return next();
|
if (req.method !== "POST" || !isDestructivePath(req.path)) return next();
|
||||||
const confirmation = consumeConfirmation(req, normalizeAction(req.originalUrl));
|
const confirmation = consumeConfirmation(req, normalizeAction(req.originalUrl));
|
||||||
@ -2609,6 +2781,8 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
res.locals.userInitial = req.session.user?.username
|
res.locals.userInitial = req.session.user?.username
|
||||||
? req.session.user.username.charAt(0).toUpperCase()
|
? req.session.user.username.charAt(0).toUpperCase()
|
||||||
: "";
|
: "";
|
||||||
|
res.locals.feedbackNotifications = notificationSummary(req.session.user?.id);
|
||||||
|
res.locals.feedbackOptions = feedbackOptions();
|
||||||
req.session.flash = null;
|
req.session.flash = null;
|
||||||
trackModRole(db, req.session.user);
|
trackModRole(db, req.session.user);
|
||||||
res.locals.navSections = buildNavSections(
|
res.locals.navSections = buildNavSections(
|
||||||
@ -3663,6 +3837,89 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
res.redirect("/profile");
|
res.redirect("/profile");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/feedback", requireAuth, (req, res) => {
|
||||||
|
markFeedbackViewed(req.session.user.id);
|
||||||
|
res.locals.feedbackNotifications = notificationSummary(req.session.user.id);
|
||||||
|
const selected = req.query.id
|
||||||
|
? getFeedbackForViewer(String(req.query.id), req.session.user.id)
|
||||||
|
: null;
|
||||||
|
res.render("feedback", {
|
||||||
|
title: "Feedback",
|
||||||
|
feedbackList: listPublicFeedback({ userId: req.session.user.id }),
|
||||||
|
myFeedback: listMyFeedback(req.session.user.id),
|
||||||
|
selectedFeedback: selected,
|
||||||
|
feedbackOptions: feedbackOptions()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/feedback/:id/comment", requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
addSubmitterComment(req.params.id, req.body.comment, req.session.user);
|
||||||
|
setFlash(req, "success", "Comment added.");
|
||||||
|
} catch (error) {
|
||||||
|
setFlash(req, "error", error.message);
|
||||||
|
}
|
||||||
|
res.redirect(`/feedback?id=${encodeURIComponent(req.params.id)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.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() });
|
||||||
@ -4922,6 +5179,122 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
res.send(lines.join("\n"));
|
res.send(lines.join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/admin/feedback", requireRole("admin"), (req, res) => {
|
||||||
|
res.render("admin-feedback", {
|
||||||
|
title: "Feedback review",
|
||||||
|
feedbackItems: listFeedbackForAdmin(req.query),
|
||||||
|
filters: {
|
||||||
|
status: req.query.status || "",
|
||||||
|
category: req.query.category || "",
|
||||||
|
severity: req.query.severity || "",
|
||||||
|
scope: req.query.scope || "",
|
||||||
|
area: req.query.area || "",
|
||||||
|
submitter: req.query.submitter || "",
|
||||||
|
date_from: req.query.date_from || "",
|
||||||
|
date_to: req.query.date_to || "",
|
||||||
|
needs_action: req.query.needs_action || "",
|
||||||
|
sort: req.query.sort || "last_activity"
|
||||||
|
},
|
||||||
|
feedbackOptions: feedbackOptions()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/feedback/:id", requireRole("admin"), (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = { ...req.body };
|
||||||
|
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) {
|
||||||
|
setFlash(req, "error", error.message);
|
||||||
|
}
|
||||||
|
res.redirect(req.get("referer") || "/admin/feedback");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/feedback/:id/finalize", requireRole("admin"), (req, res) => {
|
||||||
|
try {
|
||||||
|
adminUpdateFeedback(req.params.id, {
|
||||||
|
...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) {
|
||||||
|
setFlash(req, "error", error.message);
|
||||||
|
}
|
||||||
|
res.redirect(req.get("referer") || "/admin/feedback");
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/admin/privileges", requireRole("admin"), async (req, res) => {
|
app.get("/admin/privileges", requireRole("admin"), async (req, res) => {
|
||||||
const discord = await buildDiscordPrivileges(discordClient);
|
const discord = await buildDiscordPrivileges(discordClient);
|
||||||
const twitch = await buildTwitchPrivileges();
|
const twitch = await buildTwitchPrivileges();
|
||||||
@ -5829,6 +6202,13 @@ function collectNavItems(user, pluginNav, currentPath) {
|
|||||||
authRequired: true,
|
authRequired: true,
|
||||||
section: "community"
|
section: "community"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Feedback",
|
||||||
|
path: "/feedback",
|
||||||
|
role: "public",
|
||||||
|
authRequired: true,
|
||||||
|
section: "community"
|
||||||
|
},
|
||||||
{ label: "Mods List", path: "/moderator", role: "mod", section: "moderation" },
|
{ label: "Mods List", path: "/moderator", role: "mod", section: "moderation" },
|
||||||
{ label: "Admin", path: "/admin", role: "admin", section: "admin" },
|
{ label: "Admin", path: "/admin", role: "admin", section: "admin" },
|
||||||
{
|
{
|
||||||
@ -5855,6 +6235,7 @@ function collectNavItems(user, pluginNav, currentPath) {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
section: "admin"
|
section: "admin"
|
||||||
},
|
},
|
||||||
|
{ label: "Feedback review", path: "/admin/feedback", role: "admin", section: "admin" },
|
||||||
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
{ label: "Logs", path: "/admin/logs", role: "admin", section: "admin" },
|
||||||
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
{ label: "Updates", path: "/admin/updates", role: "admin", section: "admin" },
|
||||||
{
|
{
|
||||||
|
|||||||
274
src/web/views/admin-feedback.ejs
Normal file
274
src/web/views/admin-feedback.ejs
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<%- include("partials/page-header", {
|
||||||
|
eyebrow: "Administration",
|
||||||
|
pageTitle: "Feedback review",
|
||||||
|
description: "Review core feedback, reply to submitters, record private work notes, and close or delete resolved reports."
|
||||||
|
}) %>
|
||||||
|
<form method="get" action="/admin/feedback" class="log-controls feedback-admin-filters">
|
||||||
|
<label>
|
||||||
|
<span>Status</span>
|
||||||
|
<select name="status">
|
||||||
|
<option value="">Active statuses</option>
|
||||||
|
<% feedbackOptions.statuses.forEach((status) => { %>
|
||||||
|
<option value="<%= status.value %>" <%= filters.status === status.value ? "selected" : "" %>><%= status.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Category</span>
|
||||||
|
<select name="category">
|
||||||
|
<option value="">All categories</option>
|
||||||
|
<% feedbackOptions.categories.forEach((category) => { %>
|
||||||
|
<option value="<%= category.value %>" <%= filters.category === category.value ? "selected" : "" %>><%= category.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Severity</span>
|
||||||
|
<select name="severity">
|
||||||
|
<option value="">All severities</option>
|
||||||
|
<% feedbackOptions.severities.forEach((severity) => { %>
|
||||||
|
<option value="<%= severity.value %>" <%= filters.severity === severity.value ? "selected" : "" %>><%= severity.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Scope</span>
|
||||||
|
<select name="scope">
|
||||||
|
<option value="">All scopes</option>
|
||||||
|
<% feedbackOptions.scopes.forEach((scope) => { %>
|
||||||
|
<option value="<%= scope.value %>" <%= filters.scope === scope.value ? "selected" : "" %>><%= scope.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Plugin/area</span>
|
||||||
|
<input name="area" value="<%= filters.area %>" placeholder="URL, plugin, page, area" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Submitter</span>
|
||||||
|
<input name="submitter" value="<%= filters.submitter %>" placeholder="Name or user ID" />
|
||||||
|
</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>
|
||||||
|
<span>Sort</span>
|
||||||
|
<select name="sort">
|
||||||
|
<% [
|
||||||
|
["last_activity", "Last activity"],
|
||||||
|
["newest", "Newest"],
|
||||||
|
["oldest", "Oldest"],
|
||||||
|
["severity", "Severity"],
|
||||||
|
["status", "Status"]
|
||||||
|
].forEach(([value, label]) => { %>
|
||||||
|
<option value="<%= value %>" <%= filters.sort === value ? "selected" : "" %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-inline">
|
||||||
|
<input type="checkbox" name="needs_action" value="1" <%= filters.needs_action === "1" ? "checked" : "" %> />
|
||||||
|
<span>Needs admin action</span>
|
||||||
|
</label>
|
||||||
|
<button class="button subtle" type="submit">Filter</button>
|
||||||
|
<a class="button subtle" href="/admin/feedback">Reset</a>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Feedback queue</h2>
|
||||||
|
<p class="hint"><%= feedbackItems.length %> item(s) shown.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (!feedbackItems.length) { %>
|
||||||
|
<div class="empty-state">No feedback matches this filter.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="feedback-admin-list">
|
||||||
|
<% feedbackItems.forEach((item) => { %>
|
||||||
|
<details class="feedback-admin-item">
|
||||||
|
<summary>
|
||||||
|
<span>
|
||||||
|
<strong><%= item.summary %></strong>
|
||||||
|
<small><%= item.submitter_name || item.submitter_id %> · <%= item.category_label %> · <%= item.scope_label_display %> · <%= item.support_count || 0 %> also affected</small>
|
||||||
|
</span>
|
||||||
|
<span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span>
|
||||||
|
</summary>
|
||||||
|
<div class="feedback-admin-body">
|
||||||
|
<div class="feedback-detail-grid">
|
||||||
|
<div><span>Submitter</span><strong><%= item.submitter_name || item.submitter_id %></strong></div>
|
||||||
|
<div><span>Severity</span><strong><%= item.severity_label %></strong></div>
|
||||||
|
<div><span>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>Last activity</span><strong><%= new Date(item.last_activity_at).toLocaleString() %></strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback-copy-block">
|
||||||
|
<strong>Description</strong>
|
||||||
|
<p><%= item.description %></p>
|
||||||
|
</div>
|
||||||
|
<% if (item.steps_to_reproduce) { %><div class="feedback-copy-block"><strong>Steps</strong><p><%= item.steps_to_reproduce %></p></div><% } %>
|
||||||
|
<div class="feedback-two-col">
|
||||||
|
<div class="feedback-copy-block"><strong>Expected</strong><p><%= item.expected_behavior || "-" %></p></div>
|
||||||
|
<div class="feedback-copy-block"><strong>Actual</strong><p><%= item.actual_behavior || "-" %></p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="feedback-metadata">
|
||||||
|
<summary>Target and diagnostics</summary>
|
||||||
|
<div class="feedback-two-col">
|
||||||
|
<pre><%= JSON.stringify(item.target_metadata || {}, null, 2) %></pre>
|
||||||
|
<pre><%= JSON.stringify(item.diagnostics || {}, null, 2) %></pre>
|
||||||
|
</div>
|
||||||
|
<% if (item.current_url) { %><p><strong>URL:</strong> <a href="<%= item.current_url %>"><%= item.current_url %></a></p><% } %>
|
||||||
|
<% if (item.page_title) { %><p><strong>Page title:</strong> <%= item.page_title %></p><% } %>
|
||||||
|
<% 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>
|
||||||
|
|
||||||
|
<div class="feedback-comments">
|
||||||
|
<h3>Comments and notes</h3>
|
||||||
|
<% if (!item.comments.length) { %>
|
||||||
|
<p class="hint">No comments or notes yet.</p>
|
||||||
|
<% } %>
|
||||||
|
<% item.comments.forEach((comment) => { %>
|
||||||
|
<article class="feedback-comment feedback-comment-<%= comment.kind %>">
|
||||||
|
<strong><%= comment.kind_label %></strong>
|
||||||
|
<p><%= comment.body %></p>
|
||||||
|
<span class="hint"><%= comment.actor_name || comment.actor_id %> · <%= new Date(comment.created_at).toLocaleString() %><%= comment.visible_to_submitter ? "" : " · private" %></span>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="feedback-metadata">
|
||||||
|
<summary>Status history</summary>
|
||||||
|
<ul class="feedback-history">
|
||||||
|
<% item.history.forEach((history) => { %>
|
||||||
|
<li><strong><%= history.status_label %></strong> <span class="hint"><%= new Date(history.created_at).toLocaleString() %> · <%= history.actor_name || history.actor_id || "System" %></span><% if (history.note) { %><p><%= history.note %></p><% } %></li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/feedback/<%= item.id %>" class="form-grid feedback-admin-form" data-confirm-mode="modal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Status</label>
|
||||||
|
<select name="status">
|
||||||
|
<% feedbackOptions.statuses.forEach((status) => { %>
|
||||||
|
<option value="<%= status.value %>" <%= item.status === status.value ? "selected" : "" %>><%= status.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Category</label>
|
||||||
|
<select name="category">
|
||||||
|
<% feedbackOptions.categories.forEach((category) => { %>
|
||||||
|
<option value="<%= category.value %>" <%= item.category === category.value ? "selected" : "" %>><%= category.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Severity</label>
|
||||||
|
<select name="severity">
|
||||||
|
<% feedbackOptions.severities.forEach((severity) => { %>
|
||||||
|
<option value="<%= severity.value %>" <%= item.severity === severity.value ? "selected" : "" %>><%= severity.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Issue link</label>
|
||||||
|
<input name="linked_issue" value="<%= item.linked_issue || "" %>" placeholder="Optional URL or issue ID" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Correction link</label>
|
||||||
|
<input name="linked_correction" value="<%= item.linked_correction || "" %>" placeholder="Optional OKF/correction link" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Status note</label>
|
||||||
|
<input name="status_note" placeholder="Optional note for status history" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Reply visible to submitter</label>
|
||||||
|
<textarea name="admin_reply" rows="3"><%= item.admin_reply || "" %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Private work note</label>
|
||||||
|
<textarea name="work_note" rows="3" placeholder="Only admins can see this note."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full button-group centered">
|
||||||
|
<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 & 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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
172
src/web/views/feedback.ejs
Normal file
172
src/web/views/feedback.ejs
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<%- include("partials/page-header", {
|
||||||
|
eyebrow: "Community",
|
||||||
|
pageTitle: "Feedback",
|
||||||
|
description: "Submit one clear report per issue and track your own feedback."
|
||||||
|
}) %>
|
||||||
|
<div class="feedback-page-actions">
|
||||||
|
<button type="button" class="button" data-feedback-open data-feedback-scope="page">Send feedback</button>
|
||||||
|
<p class="hint">Small, specific reports are easier to review than broad combined lists.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card feedback-notification-summary">
|
||||||
|
<h2>Your feedback notifications</h2>
|
||||||
|
<div class="feedback-badge-row">
|
||||||
|
<span class="feedback-status-pill solved">Solved <strong><%= feedbackNotifications.solved || 0 %></strong></span>
|
||||||
|
<span class="feedback-status-pill needs-context">Needs more context <strong><%= feedbackNotifications.needs_context || 0 %></strong></span>
|
||||||
|
<span class="feedback-status-pill not-worked">Not being worked on <strong><%= feedbackNotifications.not_worked || 0 %></strong></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Opening this page marks current feedback updates as viewed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>My feedback</h2>
|
||||||
|
<% if (!myFeedback.length) { %>
|
||||||
|
<div class="empty-state">You have not submitted feedback yet.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="feedback-list">
|
||||||
|
<% myFeedback.forEach((item) => { %>
|
||||||
|
<article class="feedback-row <%= selectedFeedback && selectedFeedback.id === item.id ? 'is-selected' : '' %>">
|
||||||
|
<div>
|
||||||
|
<h3><a href="/feedback?id=<%= encodeURIComponent(item.id) %>"><%= item.summary %></a></h3>
|
||||||
|
<p class="hint"><%= item.category_label %> · <%= item.scope_label_display %> · <%= new Date(item.updated_at).toLocaleString() %></p>
|
||||||
|
</div>
|
||||||
|
<span class="status-indicator feedback-status-<%= item.status %>" title="<%= item.status_help %>"><%= item.status_label %></span>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (selectedFeedback) { %>
|
||||||
|
<section class="card feedback-detail">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2><%= selectedFeedback.summary %></h2>
|
||||||
|
<p class="hint"><%= selectedFeedback.status_label %>: <%= selectedFeedback.status_help %></p>
|
||||||
|
</div>
|
||||||
|
<span class="status-indicator feedback-status-<%= selectedFeedback.status %>"><%= selectedFeedback.status_label %></span>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-detail-grid">
|
||||||
|
<div><span>Category</span><strong><%= selectedFeedback.category_label %></strong></div>
|
||||||
|
<div><span>Severity</span><strong><%= selectedFeedback.severity_label %></strong></div>
|
||||||
|
<div><span>Scope</span><strong><%= selectedFeedback.scope_label_display %></strong></div>
|
||||||
|
<div><span>Submitted</span><strong><%= new Date(selectedFeedback.created_at).toLocaleString() %></strong></div>
|
||||||
|
</div>
|
||||||
|
<% 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">
|
||||||
|
<strong>Description</strong>
|
||||||
|
<p><%= selectedFeedback.description %></p>
|
||||||
|
</div>
|
||||||
|
<% if (selectedFeedback.steps_to_reproduce) { %>
|
||||||
|
<div class="feedback-copy-block"><strong>Steps to reproduce</strong><p><%= selectedFeedback.steps_to_reproduce %></p></div>
|
||||||
|
<% } %>
|
||||||
|
<% if (selectedFeedback.expected_behavior || selectedFeedback.actual_behavior) { %>
|
||||||
|
<div class="feedback-two-col">
|
||||||
|
<div class="feedback-copy-block"><strong>Expected</strong><p><%= selectedFeedback.expected_behavior || "-" %></p></div>
|
||||||
|
<div class="feedback-copy-block"><strong>Actual</strong><p><%= selectedFeedback.actual_behavior || "-" %></p></div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (selectedFeedback.admin_reply) { %>
|
||||||
|
<div class="callout success"><strong>Admin reply</strong><p><%= selectedFeedback.admin_reply %></p></div>
|
||||||
|
<% } %>
|
||||||
|
<% 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>
|
||||||
|
<% if (!selectedFeedback.comments.length) { %>
|
||||||
|
<p class="hint">No comments yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="feedback-comments">
|
||||||
|
<% selectedFeedback.comments.forEach((comment) => { %>
|
||||||
|
<article class="feedback-comment feedback-comment-<%= comment.kind %>">
|
||||||
|
<strong><%= comment.kind_label %></strong>
|
||||||
|
<p><%= comment.body %></p>
|
||||||
|
<span class="hint"><%= comment.actor_name || "User" %> · <%= new Date(comment.created_at).toLocaleString() %></span>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% 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">
|
||||||
|
<div class="field full">
|
||||||
|
<label><%= selectedFeedback.is_mine ? "Add more context" : "Add public context" %></label>
|
||||||
|
<textarea name="comment" rows="4" required></textarea>
|
||||||
|
<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 class="field full">
|
||||||
|
<button type="submit" class="button">Add comment</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Community feedback overview</h2>
|
||||||
|
<p class="hint">This list hides submitter identity and detailed diagnostics.</p>
|
||||||
|
<% if (!feedbackList.length) { %>
|
||||||
|
<div class="empty-state">No feedback has been submitted yet.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Summary</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% feedbackList.forEach((item) => { %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= item.summary %>
|
||||||
|
<% if (item.is_mine) { %><span class="badge">Mine</span><% } %>
|
||||||
|
<% if (item.support_count) { %><span class="badge"><%= item.support_count %> also affected</span><% } %>
|
||||||
|
</td>
|
||||||
|
<td><%= item.category_label %></td>
|
||||||
|
<td><%= item.scope_label_display %></td>
|
||||||
|
<td><span class="status-indicator feedback-status-<%= item.status %>"><%= item.status_label %></span></td>
|
||||||
|
<td>
|
||||||
|
<%= new Date(item.updated_at).toLocaleString() %>
|
||||||
|
<% 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>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
@ -11,6 +11,131 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
|
<button class="sidebar-scrim" type="button" data-sidebar-dismiss aria-label="Close navigation"></button>
|
||||||
|
<% if (user) { %>
|
||||||
|
<% const feedbackNotice = typeof feedbackNotifications !== "undefined" ? feedbackNotifications : { total: 0, solved: 0, needs_context: 0, not_worked: 0 }; %>
|
||||||
|
<% const feedbackOptionData = typeof feedbackOptions !== "undefined" ? feedbackOptions : {}; %>
|
||||||
|
<button class="feedback-floating-button" type="button" data-feedback-open data-feedback-scope="page" aria-label="Send feedback">
|
||||||
|
Feedback
|
||||||
|
<% if (feedbackNotice && feedbackNotice.total) { %>
|
||||||
|
<span class="feedback-floating-badge" aria-label="<%= feedbackNotice.total %> feedback update(s)"><%= feedbackNotice.total %></span>
|
||||||
|
<% } %>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="modal-backdrop feedback-modal-backdrop"
|
||||||
|
data-feedback-modal
|
||||||
|
aria-hidden="true"
|
||||||
|
data-feedback-options="<%= JSON.stringify(feedbackOptionData || {}) %>"
|
||||||
|
>
|
||||||
|
<div class="modal feedback-modal" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="feedback-modal-title">Send feedback</h3>
|
||||||
|
<button type="button" class="icon-button" data-feedback-close aria-label="Close feedback">×</button>
|
||||||
|
</div>
|
||||||
|
<form class="form-grid" data-feedback-form>
|
||||||
|
<input type="hidden" name="target_metadata" data-feedback-target-metadata />
|
||||||
|
<input type="hidden" name="current_url" data-feedback-current-url />
|
||||||
|
<input type="hidden" name="page_title" data-feedback-page-title />
|
||||||
|
<input type="hidden" name="diagnostics" data-feedback-diagnostics />
|
||||||
|
<div class="field full">
|
||||||
|
<label>Short summary</label>
|
||||||
|
<input name="summary" maxlength="140" required placeholder="Example: Save button does nothing" />
|
||||||
|
</div>
|
||||||
|
<div class="field 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">
|
||||||
|
<label>Category</label>
|
||||||
|
<select name="category" data-feedback-category required></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Severity</label>
|
||||||
|
<select name="severity" data-feedback-severity required></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Feedback is about</label>
|
||||||
|
<select name="scope_type" data-feedback-scope-type required></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Detected target</label>
|
||||||
|
<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 class="field full">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea name="description" rows="5" required placeholder="Describe one specific issue or suggestion."></textarea>
|
||||||
|
<span class="hint">Please submit one feedback item per issue. Do not include passwords, tokens, or private messages.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>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">
|
||||||
|
<label>Steps to reproduce <span class="hint">optional</span></label>
|
||||||
|
<textarea name="steps_to_reproduce" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Expected behavior <span class="hint">optional</span></label>
|
||||||
|
<textarea name="expected_behavior" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Actual behavior <span class="hint">optional</span></label>
|
||||||
|
<textarea name="actual_behavior" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label class="checkbox-inline">
|
||||||
|
<input type="checkbox" data-feedback-include-browser />
|
||||||
|
<span>Include browser and viewport details</span>
|
||||||
|
</label>
|
||||||
|
<span class="hint">This attaches your browser name, page size, language, URL, and page title. Field values are not collected.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<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="modal-actions">
|
||||||
|
<button type="button" class="button subtle" data-feedback-close>Cancel</button>
|
||||||
|
<button type="submit" class="button">Submit feedback</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lumi-context-menu" data-lumi-context-menu hidden role="menu" aria-label="Page actions">
|
||||||
|
<button type="button" role="menuitem" data-context-action="back" 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" data-context-tooltip="Return to the next page after going back.">Forward</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" 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" data-context-tooltip="Insert the current clipboard text into the active field.">Paste</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" 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" data-context-tooltip="Create feedback for admins. Right-click a specific element to attach it as context.">Feedback</button>
|
||||||
|
</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">
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="destructive-confirm-title">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user