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;
}

View File

@ -1,317 +1,317 @@
import {
STICKER_CATEGORIES,
} from "@/lib/stickers/categories";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
import type { StickerCategory } from "@/lib/stickers/types";
import { stickersRegistry } from "./registry";
import { resolveStickerId } from "./resolver";
import { registerDefaultStickerProviders } from "./providers";
import { parseStickerId } from "./sticker-id";
import type {
StickerBrowseResult,
StickerItem,
StickerProvider,
StickerSearchResult,
} from "./types";
const DEFAULT_BROWSE_LIMIT = 12;
const DEFAULT_SEARCH_LIMIT = 100;
function mergeSearchResults({
results,
}: {
results: StickerSearchResult[];
}): StickerSearchResult {
const deduplicatedItems = new Map<
string,
StickerSearchResult["items"][number]
>();
let total = 0;
let hasMore = false;
for (const result of results) {
total += result.total;
hasMore = hasMore || result.hasMore;
for (const item of result.items) {
if (!deduplicatedItems.has(item.id)) {
deduplicatedItems.set(item.id, item);
}
}
}
return {
items: Array.from(deduplicatedItems.values()),
total,
hasMore,
};
}
function getProviderByCategory({
category,
}: {
category: StickerCategory;
}): StickerProvider | null {
if (category === "all") {
return null;
}
try {
return stickersRegistry.get(category);
} catch {
return null;
}
}
function getEmptyBrowseResult(): StickerBrowseResult {
return {
sections: [],
};
}
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
const stickerIdParts = stickerId.split(":");
if (stickerIdParts.length <= 1) {
return stickerId;
}
return (
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
stickerId
);
}
function toRecentStickerItem({
stickerId,
}: {
stickerId: string;
}): StickerItem | null {
try {
const { providerId } = parseStickerId({ stickerId });
return {
id: stickerId,
provider: providerId,
name: getStickerNameFromId({ stickerId }),
previewUrl: resolveStickerId({
stickerId,
options: { width: 64, height: 64 },
}),
metadata: {},
};
} catch {
return null;
}
}
export async function searchStickers({
query,
category,
limit = DEFAULT_SEARCH_LIMIT,
}: {
query: string;
category: StickerCategory;
limit?: number;
}): Promise<StickerSearchResult> {
registerDefaultStickerProviders({});
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
if (effectiveCategory !== "all") {
const provider = getProviderByCategory({ category: effectiveCategory });
if (!provider) {
return {
items: [],
total: 0,
hasMore: false,
};
}
return provider.search({
query,
options: { limit },
});
}
const providers = stickersRegistry.getAll();
if (providers.length === 0) {
return {
items: [],
total: 0,
hasMore: false,
};
}
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
const settledResults = await Promise.allSettled(
providers.map((provider) =>
provider.search({
query,
options: { limit: perProviderLimit },
}),
),
);
const fulfilledResults = settledResults
.filter(
(result): result is PromiseFulfilledResult<StickerSearchResult> =>
result.status === "fulfilled",
)
.map((result) => result.value);
return mergeSearchResults({
results: fulfilledResults,
});
}
export async function searchAll({
query,
limit = DEFAULT_SEARCH_LIMIT,
}: {
query: string;
limit?: number;
}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
const providers = stickersRegistry.getAll();
if (providers.length === 0) {
return { sections: [] };
}
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
const settledResults = await Promise.allSettled(
providers.map(async (provider) => {
const result = await provider.search({
query,
options: { limit: perProviderLimit },
});
return { provider, result };
}),
);
const sections: StickerBrowseResult["sections"] = [];
for (const settled of settledResults) {
if (settled.status !== "fulfilled") {
continue;
}
const { provider, result } = settled.value;
if (result.items.length === 0) {
continue;
}
const category = provider.id as StickerCategory;
sections.push({
id: category,
title: STICKER_CATEGORIES[category] ?? provider.id,
items: result.items,
hasMore: result.hasMore,
layout: "grid",
});
}
return { sections };
}
export async function browseCategory({
category,
}: {
category: StickerCategory;
}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
if (effectiveCategory === "all") {
return getEmptyBrowseResult();
}
const provider = getProviderByCategory({ category: effectiveCategory });
if (!provider) {
return getEmptyBrowseResult();
}
return provider.browse({ options: {} });
}
export async function browseAll({
recentStickers,
limit = DEFAULT_BROWSE_LIMIT,
}: {
recentStickers: string[];
limit?: number;
}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
const sections: StickerBrowseResult["sections"] = [];
const recentItems = recentStickers
.map((stickerId) => toRecentStickerItem({ stickerId }))
.filter((item): item is StickerItem => item !== null);
if (recentItems.length > 0) {
sections.push({
id: "recent",
title: "Recently used",
items: recentItems.slice(0, limit),
hasMore: recentItems.length > limit,
layout: "row",
});
}
const settledResults = await Promise.allSettled(
stickersRegistry.getAll().map(async (provider) => {
const browseResult = await provider.browse({
options: { limit },
});
const firstSection = browseResult.sections[0];
if (!firstSection || firstSection.items.length === 0) {
return null;
}
const category = provider.id as StickerCategory;
return {
...firstSection,
id: category,
title: STICKER_CATEGORIES[category] ?? firstSection.title,
layout: "row" as const,
action: {
type: "see-all" as const,
category,
sectionId: firstSection.id,
},
};
}),
);
for (const result of settledResults) {
if (result.status === "fulfilled" && result.value) {
sections.push(result.value);
}
}
return { sections };
}
export async function resolveStickerIntrinsicSize({
stickerId,
}: {
stickerId: string;
}): Promise<{ width: number; height: number }> {
const url = resolveStickerId({ stickerId });
return new Promise((resolve) => {
const img = new Image();
img.onload = () =>
resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () =>
resolve({
width: STICKER_INTRINSIC_SIZE_FALLBACK,
height: STICKER_INTRINSIC_SIZE_FALLBACK,
});
img.src = url;
});
}
export { resolveStickerId };
export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
export type {
StickerBrowseResult,
StickerBrowseSection,
StickerCategory,
StickerItem,
StickerProvider,
StickerResolveOptions,
StickerSearchResult,
} from "./types";
import {
STICKER_CATEGORIES,
} from "@/lib/stickers/categories";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
import type { StickerCategory } from "@/lib/stickers/types";
import { stickersRegistry } from "./registry";
import { resolveStickerId } from "./resolver";
import { registerDefaultStickerProviders } from "./providers";
import { parseStickerId } from "./sticker-id";
import type {
StickerBrowseResult,
StickerItem,
StickerProvider,
StickerSearchResult,
} from "./types";
const DEFAULT_BROWSE_LIMIT = 12;
const DEFAULT_SEARCH_LIMIT = 100;
function mergeSearchResults({
results,
}: {
results: StickerSearchResult[];
}): StickerSearchResult {
const deduplicatedItems = new Map<
string,
StickerSearchResult["items"][number]
>();
let total = 0;
let hasMore = false;
for (const result of results) {
total += result.total;
hasMore = hasMore || result.hasMore;
for (const item of result.items) {
if (!deduplicatedItems.has(item.id)) {
deduplicatedItems.set(item.id, item);
}
}
}
return {
items: Array.from(deduplicatedItems.values()),
total,
hasMore,
};
}
function getProviderByCategory({
category,
}: {
category: StickerCategory;
}): StickerProvider | null {
if (category === "all") {
return null;
}
try {
return stickersRegistry.get(category);
} catch {
return null;
}
}
function getEmptyBrowseResult(): StickerBrowseResult {
return {
sections: [],
};
}
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
const stickerIdParts = stickerId.split(":");
if (stickerIdParts.length <= 1) {
return stickerId;
}
return (
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
stickerId
);
}
function toRecentStickerItem({
stickerId,
}: {
stickerId: string;
}): StickerItem | null {
try {
const { providerId } = parseStickerId({ stickerId });
return {
id: stickerId,
provider: providerId,
name: getStickerNameFromId({ stickerId }),
previewUrl: resolveStickerId({
stickerId,
options: { width: 64, height: 64 },
}),
metadata: {},
};
} catch {
return null;
}
}
export async function searchStickers({
query,
category,
limit = DEFAULT_SEARCH_LIMIT,
}: {
query: string;
category: StickerCategory;
limit?: number;
}): Promise<StickerSearchResult> {
registerDefaultStickerProviders({});
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
if (effectiveCategory !== "all") {
const provider = getProviderByCategory({ category: effectiveCategory });
if (!provider) {
return {
items: [],
total: 0,
hasMore: false,
};
}
return provider.search({
query,
options: { limit },
});
}
const providers = stickersRegistry.getAll();
if (providers.length === 0) {
return {
items: [],
total: 0,
hasMore: false,
};
}
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
const settledResults = await Promise.allSettled(
providers.map((provider) =>
provider.search({
query,
options: { limit: perProviderLimit },
}),
),
);
const fulfilledResults = settledResults
.filter(
(result): result is PromiseFulfilledResult<StickerSearchResult> =>
result.status === "fulfilled",
)
.map((result) => result.value);
return mergeSearchResults({
results: fulfilledResults,
});
}
export async function searchAll({
query,
limit = DEFAULT_SEARCH_LIMIT,
}: {
query: string;
limit?: number;
}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
const providers = stickersRegistry.getAll();
if (providers.length === 0) {
return { sections: [] };
}
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
const settledResults = await Promise.allSettled(
providers.map(async (provider) => {
const result = await provider.search({
query,
options: { limit: perProviderLimit },
});
return { provider, result };
}),
);
const sections: StickerBrowseResult["sections"] = [];
for (const settled of settledResults) {
if (settled.status !== "fulfilled") {
continue;
}
const { provider, result } = settled.value;
if (result.items.length === 0) {
continue;
}
const category = provider.id as StickerCategory;
sections.push({
id: category,
title: STICKER_CATEGORIES[category] ?? provider.id,
items: result.items,
hasMore: result.hasMore,
layout: "grid",
});
}
return { sections };
}
export async function browseCategory({
category,
}: {
category: StickerCategory;
}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
if (effectiveCategory === "all") {
return getEmptyBrowseResult();
}
const provider = getProviderByCategory({ category: effectiveCategory });
if (!provider) {
return getEmptyBrowseResult();
}
return provider.browse({ options: {} });
}
export async function browseAll({
recentStickers,
limit = DEFAULT_BROWSE_LIMIT,
}: {
recentStickers: string[];
limit?: number;
}): Promise<StickerBrowseResult> {
registerDefaultStickerProviders({});
const sections: StickerBrowseResult["sections"] = [];
const recentItems = recentStickers
.map((stickerId) => toRecentStickerItem({ stickerId }))
.filter((item): item is StickerItem => item !== null);
if (recentItems.length > 0) {
sections.push({
id: "recent",
title: "Recently used",
items: recentItems.slice(0, limit),
hasMore: recentItems.length > limit,
layout: "row",
});
}
const settledResults = await Promise.allSettled(
stickersRegistry.getAll().map(async (provider) => {
const browseResult = await provider.browse({
options: { limit },
});
const firstSection = browseResult.sections[0];
if (!firstSection || firstSection.items.length === 0) {
return null;
}
const category = provider.id as StickerCategory;
return {
...firstSection,
id: category,
title: STICKER_CATEGORIES[category] ?? firstSection.title,
layout: "row" as const,
action: {
type: "see-all" as const,
category,
sectionId: firstSection.id,
},
};
}),
);
for (const result of settledResults) {
if (result.status === "fulfilled" && result.value) {
sections.push(result.value);
}
}
return { sections };
}
export async function resolveStickerIntrinsicSize({
stickerId,
}: {
stickerId: string;
}): Promise<{ width: number; height: number }> {
const url = resolveStickerId({ stickerId });
return new Promise((resolve) => {
const img = new Image();
img.onload = () =>
resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () =>
resolve({
width: STICKER_INTRINSIC_SIZE_FALLBACK,
height: STICKER_INTRINSIC_SIZE_FALLBACK,
});
img.src = url;
});
}
export { resolveStickerId };
export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
export type {
StickerBrowseResult,
StickerBrowseSection,
StickerCategory,
StickerItem,
StickerProvider,
StickerResolveOptions,
StickerSearchResult,
} from "./types";