Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,10 @@ function UserMenu({ user }: { user: CurrentUser }) {
busy={feedbackBusy}
done={feedbackDone}
error={feedbackError}
onSubmit={(message) => {
onSubmit={({ message, category, element }) => {
setFeedbackBusy(true);
setFeedbackError(null);
void submitFeedback({ data: { message, page: window.location.pathname } }).then((res) => {
void submitFeedback({ data: { message, category, element, page: window.location.pathname } }).then((res) => {
setFeedbackBusy(false);
if (!res.ok) {
setFeedbackError(res.error ?? "Could not send.");
Expand Down
79 changes: 72 additions & 7 deletions apps/web/src/components/feedback-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Feedback dialog — a short message straight to the team (Discord).
* Presentational: the caller owns submission.
* Presentational: the caller owns submission. When the UI category is
* selected, an optional element picker captures the exact element context.
*/
import { useEffect, useRef, useState } from "react";
import { HiCheck as Check } from "react-icons/hi2";
import { HiCheck as Check, HiCursorArrowRays as Picker, HiXMark as X } from "react-icons/hi2";

import { Button } from "@/components/ui/button";
import {
Expand All @@ -15,6 +16,23 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { pickElement, type PickedElement } from "@/lib/element-picker";

export const FEEDBACK_CATEGORIES = [
{ id: "bug", label: "Bug" },
{ id: "idea", label: "Idea" },
{ id: "ui", label: "UI issue" },
{ id: "other", label: "Other" },
] as const;

export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number]["id"];

export interface FeedbackSubmission {
message: string;
category: FeedbackCategory;
element: PickedElement | null;
}

export function FeedbackDialog({
open,
Expand All @@ -26,21 +44,38 @@ export function FeedbackDialog({
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (message: string) => void;
onSubmit: (submission: FeedbackSubmission) => void;
busy?: boolean;
done?: boolean;
error?: string | null;
}) {
const [message, setMessage] = useState("");
const [category, setCategory] = useState<FeedbackCategory>("bug");
const [element, setElement] = useState<PickedElement | null>(null);
const [picking, setPicking] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
const valid = message.trim().length >= 4;
useEffect(() => {
if (done) closeRef.current?.focus();
}, [done]);

async function startPicking() {
setPicking(true);
try {
const picked = await pickElement();
if (picked) setElement(picked);
} finally {
setPicking(false);
}
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md overflow-hidden p-0">
<Dialog open={open} onOpenChange={(next) => { if (!picking) onOpenChange(next); }} modal={!picking}>
<DialogContent
className={`max-w-md overflow-hidden p-0 ${picking ? "invisible" : ""}`}
onEscapeKeyDown={(event) => { if (picking) event.preventDefault(); }}
onInteractOutside={(event) => { if (picking) event.preventDefault(); }}
>
<DialogHeader className="gap-2 px-6 pb-0 pt-6 pr-12">
<DialogTitle>Send feedback</DialogTitle>
<DialogDescription className="max-w-sm leading-relaxed">
Expand All @@ -51,7 +86,7 @@ export function FeedbackDialog({
aria-busy={busy}
onSubmit={(event) => {
event.preventDefault();
if (valid && !busy) onSubmit(message.trim());
if (valid && !busy) onSubmit({ message: message.trim(), category, element: category === "ui" ? element : null });
}}
>
<div className="flex min-h-56 flex-col px-6 pb-6 pt-5">
Expand All @@ -60,6 +95,35 @@ export function FeedbackDialog({
<Check className="size-4" aria-hidden="true" /> Sent — thank you.
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="feedback-category">Category</Label>
<Select value={category} onValueChange={(value) => setCategory(value as FeedbackCategory)}>
<SelectTrigger id="feedback-category" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{FEEDBACK_CATEGORIES.map((item) => <SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{category === "ui" ? (
<div className="flex flex-col gap-2">
{element ? (
<div className="flex items-start justify-between gap-2 rounded-lg border border-border/60 bg-secondary/25 px-3 py-2">
<div className="min-w-0 text-xs leading-relaxed">
<p className="truncate font-mono">{element.selector}</p>
<p className="mt-0.5 truncate text-muted-foreground">{element.text || element.rect}</p>
</div>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Remove picked element" className="shrink-0 text-muted-foreground hover:text-destructive" onClick={() => setElement(null)}>
<X />
</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" className="justify-start gap-2" onClick={() => void startPicking()}>
<Picker className="size-4" /> Pick the element on this page (optional)
</Button>
)}
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="feedback-message">Message</Label>
<textarea
Expand All @@ -72,13 +136,14 @@ export function FeedbackDialog({
placeholder="What's on your mind?"
aria-invalid={Boolean(error)}
aria-describedby="feedback-message-meta"
className="min-h-36 w-full resize-none rounded-lg border border-input bg-background/40 px-3 py-3 text-sm leading-relaxed outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="min-h-28 w-full resize-none rounded-lg border border-input bg-background/40 px-3 py-3 text-sm leading-relaxed outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div id="feedback-message-meta" className="flex min-h-5 items-start justify-between gap-4">
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : <span />}
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{message.length}/2000</span>
</div>
</div>
</div>
)}
</div>
<span className="sr-only" role="status" aria-live="polite">{busy ? "Sending feedback" : ""}</span>
Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/lib/element-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Interactive element picker for UI feedback. Highlights the hovered element
* and resolves with a compact descriptor on click, or null on Escape.
* Client-only; callers hide their own UI while picking.
*/

export interface PickedElement {
/** Best-effort unique CSS path, capped for transport. */
selector: string;
/** Visible text snippet for humans triaging the report. */
text: string;
/** Viewport rect at pick time, e.g. "120×48 @ (312, 96)". */
rect: string;
}

function cssPath(element: Element): string {
const parts: string[] = [];
let node: Element | null = element;
while (node && node !== document.documentElement && parts.length < 8) {
if (node.id) {
parts.unshift(`#${CSS.escape(node.id)}`);
break;
}
const tag = node.tagName.toLowerCase();
const parent: Element | null = node.parentElement;
if (!parent) {
parts.unshift(tag);
break;
}
const siblings = [...parent.children].filter((child) => child.tagName === node!.tagName);
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(node) + 1})` : tag);
node = parent;
}
return parts.join(" > ").slice(0, 300);
}

export function describeElement(element: Element): PickedElement {
const rect = element.getBoundingClientRect();
return {
selector: cssPath(element),
text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120),
rect: `${Math.round(rect.width)}×${Math.round(rect.height)} @ (${Math.round(rect.x)}, ${Math.round(rect.y)})`,
};
}

export function pickElement(): Promise<PickedElement | null> {
return new Promise((resolve) => {
const highlight = document.createElement("div");
highlight.setAttribute("aria-hidden", "true");
highlight.style.cssText =
"position:fixed;z-index:2147483646;pointer-events:none;border:2px solid #f5a623;border-radius:4px;background:rgba(245,166,35,0.12);transition:all 60ms ease-out;display:none;";
const hint = document.createElement("div");
hint.setAttribute("role", "status");
hint.textContent = "Click the element this feedback is about — Esc cancels";
hint.style.cssText =
"position:fixed;z-index:2147483647;left:50%;top:16px;transform:translateX(-50%);background:#111;color:#fafafa;font:500 13px/1.4 system-ui;padding:8px 14px;border-radius:999px;box-shadow:0 4px 16px rgba(0,0,0,0.35);pointer-events:none;";
document.body.appendChild(highlight);
document.body.appendChild(hint);
document.documentElement.style.cursor = "crosshair";

let current: Element | null = null;

function targetAt(event: MouseEvent): Element | null {
const element = document.elementFromPoint(event.clientX, event.clientY);
if (!element || element === highlight || element === hint || element === document.documentElement || element === document.body) return null;
return element;
}

function onMove(event: MouseEvent) {
const element = targetAt(event);
current = element;
if (!element) {
highlight.style.display = "none";
return;
}
const rect = element.getBoundingClientRect();
highlight.style.display = "block";
highlight.style.left = `${rect.left}px`;
highlight.style.top = `${rect.top}px`;
highlight.style.width = `${rect.width}px`;
highlight.style.height = `${rect.height}px`;
}

function finish(result: PickedElement | null) {
window.removeEventListener("mousemove", onMove, true);
window.removeEventListener("click", onClick, true);
window.removeEventListener("keydown", onKey, true);
highlight.remove();
hint.remove();
document.documentElement.style.cursor = "";
resolve(result);
}

function onClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const element = targetAt(event) ?? current;
finish(element ? describeElement(element) : null);
}

function onKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
finish(null);
}

window.addEventListener("mousemove", onMove, true);
window.addEventListener("click", onClick, true);
window.addEventListener("keydown", onKey, true);
});
}
23 changes: 21 additions & 2 deletions apps/web/src/lib/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@ import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { getCurrentUser } from "./session";

const CATEGORIES = new Map([
["bug", "Bug"],
["idea", "Idea"],
["ui", "UI issue"],
["other", "Other"],
]);

export const submitFeedback = createServerFn({ method: "POST" })
.validator((data: { message: string; page: string }) => ({
.validator((data: { message: string; page: string; category?: string; element?: { selector?: string; text?: string; rect?: string } | null }) => ({
message: String(data.message ?? "").trim().slice(0, 2000),
page: String(data.page ?? "").trim().slice(0, 200),
category: CATEGORIES.has(String(data.category)) ? String(data.category) : "other",
element: data.element
? {
selector: String(data.element.selector ?? "").trim().slice(0, 300),
text: String(data.element.text ?? "").trim().slice(0, 120),
rect: String(data.element.rect ?? "").trim().slice(0, 60),
}
: null,
}))
.handler(async ({ data }): Promise<{ ok: boolean; error?: string }> => {
if (data.message.length < 4) return { ok: false, error: "Say a little more." };
Expand All @@ -24,12 +39,16 @@ export const submitFeedback = createServerFn({ method: "POST" })
content: null,
embeds: [
{
title: "Feedback",
title: `Feedback — ${CATEGORIES.get(data.category) ?? "Other"}`,
description: data.message,
color: 0xf5a623,
fields: [
{ name: "From", value: `@${user.username ?? user.email}`, inline: true },
{ name: "Page", value: data.page || "unknown", inline: true },
{ name: "Category", value: CATEGORIES.get(data.category) ?? "Other", inline: true },
...(data.element?.selector
? [{ name: "Element", value: `\`${data.element.selector}\`${data.element.text ? `\n“${data.element.text}”` : ""}${data.element.rect ? `\n${data.element.rect}` : ""}`.slice(0, 1024) }]
: []),
],
timestamp: new Date().toISOString(),
},
Expand Down
Loading