feat: editor feedback

This commit is contained in:
Maze Winther 2026-04-14 21:45:11 +02:00
parent b68bf00454
commit 78a0a27a72
8 changed files with 588 additions and 317 deletions

View File

@ -0,0 +1,31 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback";
const submitSchema = z.object({
message: z
.string()
.min(1, "Message is required")
.max(MAX_MESSAGE_LENGTH, "Message too long"),
});
export async function POST(request: NextRequest) {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const body = await request.json();
const result = submitSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
{ status: 400 },
);
}
const entry = await submitFeedback(result.data);
return NextResponse.json({ entry }, { status: 201 });
}

View File

@ -15,6 +15,7 @@ import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button";
import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover";
import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
import { SOCIAL_LINKS } from "@/lib/site/social";
@ -34,6 +35,7 @@ export function EditorHeader() {
<EditableProjectName />
</div>
<nav className="flex items-center gap-2">
<FeedbackPopover />
<ExportButton />
<ThemeToggle />
</nav>

View File

@ -48,6 +48,14 @@ export const accounts = pgTable("accounts", {
updatedAt: timestamp("updated_at").notNull(),
}).enableRLS();
export const feedback = pgTable("feedback", {
id: text("id").primaryKey(),
message: text("message").notNull(),
createdAt: timestamp("created_at")
.$defaultFn(() => new Date())
.notNull(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),

View File

@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import {
Form,
FormField,
FormItem,
FormControl,
clearFormDraft,
} from "@/components/ui/form";
import type { FeedbackEntry } from "../types";
const PERSIST_KEY = "feedback-draft";
const HISTORY_KEY = "feedback-history";
const MAX_HISTORY = 20;
interface FeedbackFormValues {
message: string;
}
function readHistory(): FeedbackEntry[] {
try {
const stored = localStorage.getItem(HISTORY_KEY);
return stored ? (JSON.parse(stored) as FeedbackEntry[]) : [];
} catch {
return [];
}
}
function writeHistory({ entries }: { entries: FeedbackEntry[] }): void {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
} catch {
// localStorage may be full or unavailable
}
}
function useFeedback() {
const [entries, setEntries] = useState<FeedbackEntry[]>(readHistory);
const [isSubmitting, setIsSubmitting] = useState(false);
async function submit({
values,
onSuccess,
}: {
values: FeedbackFormValues;
onSuccess: () => void;
}) {
if (isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error ?? "Failed to submit");
}
const { entry } = await res.json();
const next = [entry, ...entries].slice(0, MAX_HISTORY);
setEntries(next);
writeHistory({ entries: next });
onSuccess();
toast.success("Feedback sent");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to send feedback",
);
} finally {
setIsSubmitting(false);
}
}
return { entries, isSubmitting, submit };
}
export function FeedbackPopover() {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-8">
Send feedback
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<FeedbackPopoverContent onClose={() => setOpen(false)} />
</PopoverContent>
</Popover>
);
}
function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
const { entries, isSubmitting, submit } = useFeedback();
const form = useForm<FeedbackFormValues>({
defaultValues: { message: "" },
});
async function handleSubmit(values: FeedbackFormValues) {
await submit({
values,
onSuccess: () => {
form.reset({ message: "" });
clearFormDraft({ key: PERSIST_KEY });
onClose();
},
});
}
return (
<div className="flex flex-col">
<Form persistKey={PERSIST_KEY} {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col">
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder="Thoughts, bugs, ideas..."
className="min-h-[7rem] text-sm p-3 bg-background shadow-none border-none! resize-none"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end border-t px-3 py-2 gap-2">
{!form.watch("message").trim() && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
>
Cancel
</Button>
)}
<Button
type="submit"
size="sm"
disabled={isSubmitting || !form.watch("message").trim()}
>
{isSubmitting ? <Spinner /> : "Send"}
</Button>
</div>
</form>
</Form>
{entries.length > 0 && (
<div className="border-t">
<div className="px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
Previous feedback
</span>
</div>
<div className="max-h-48 overflow-y-auto px-3 pb-3">
<div className="flex flex-col gap-2">
{entries.map((entry) => (
<FeedbackEntryItem key={entry.id} entry={entry} />
))}
</div>
</div>
</div>
)}
</div>
);
}
function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
const formatted = new Date(entry.createdAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
return (
<div className="rounded-md border px-2.5 py-2">
<p className="text-sm whitespace-pre-wrap break-words">{entry.message}</p>
<span className="mt-1 block text-xs text-muted-foreground">
{formatted}
</span>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { submitFeedback } from "./queries";
export { MAX_MESSAGE_LENGTH } from "./types";
export type { FeedbackEntry, SubmitFeedbackInput } from "./types";

View File

@ -0,0 +1,14 @@
import { db, feedback } from "@/lib/db";
import { generateUUID } from "@/utils/id";
import type { FeedbackEntry, SubmitFeedbackInput } from "./types";
export async function submitFeedback({
message,
}: SubmitFeedbackInput): Promise<FeedbackEntry> {
const id = generateUUID();
const now = new Date();
await db.insert(feedback).values({ id, message, createdAt: now });
return { id, message, createdAt: now.toISOString() };
}

View File

@ -0,0 +1,11 @@
export const MAX_MESSAGE_LENGTH = 5000;
export interface FeedbackEntry {
id: string;
message: string;
createdAt: string;
}
export interface SubmitFeedbackInput {
message: string;
}