summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/feedback/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/feedback/components
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/feedback/components')
-rw-r--r--apps/web/src/lib/feedback/components/feedback-popover.tsx242
1 files changed, 0 insertions, 242 deletions
diff --git a/apps/web/src/lib/feedback/components/feedback-popover.tsx b/apps/web/src/lib/feedback/components/feedback-popover.tsx
deleted file mode 100644
index f94b7253..00000000
--- a/apps/web/src/lib/feedback/components/feedback-popover.tsx
+++ /dev/null
@@ -1,242 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { ClockIcon } from "lucide-react";
-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>
- );
-}
-
-type View = "compose" | "history";
-
-function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
- const { entries, isSubmitting, submit } = useFeedback();
- const [view, setView] = useState<View>("compose");
-
- const form = useForm<FeedbackFormValues>({
- defaultValues: { message: "" },
- });
-
- async function handleSubmit(values: FeedbackFormValues) {
- await submit({
- values,
- onSuccess: () => {
- form.reset({ message: "" });
- clearFormDraft({ key: PERSIST_KEY });
- onClose();
- },
- });
- }
-
- if (view === "history") {
- return (
- <div className="flex flex-col">
- <div
- className="max-h-72 overflow-y-auto divide-y"
- style={{
- maskImage:
- "linear-gradient(to bottom, black 80%, transparent 100%)",
- }}
- >
- {entries.map((entry) => (
- <FeedbackEntryItem key={entry.id} entry={entry} />
- ))}
- </div>
- <div className="border-t px-3 py-2">
- <button
- type="button"
- onClick={() => setView("compose")}
- className="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
- >
- ← Back
- </button>
- </div>
- </div>
- );
- }
-
- 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 items-center justify-between border-t px-3 py-2">
- {entries.length > 0 ? (
- <button
- type="button"
- onClick={() => setView("history")}
- className="flex items-center gap-1.5 text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
- >
- <ClockIcon className="size-3" />
- {entries.length}
- </button>
- ) : (
- <span />
- )}
- <div className="flex 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>
- </div>
- </form>
- </Form>
- </div>
- );
-}
-
-function relativeDate(iso: string): string {
- const diff = Date.now() - new Date(iso).getTime();
- const mins = Math.floor(diff / 60_000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
- const hrs = Math.floor(mins / 60);
- if (hrs < 24) return `${hrs}h ago`;
- const days = Math.floor(hrs / 24);
- if (days < 7) return `${days}d ago`;
- return new Date(iso).toLocaleDateString(undefined, {
- month: "short",
- day: "numeric",
- });
-}
-
-function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
- return (
- <div className="px-3 py-2.5">
- <p className="text-sm text-muted-foreground leading-snug whitespace-pre-wrap break-words">
- {entry.message}
- </p>
- <span className="mt-1 block text-[11px] text-muted-foreground/50">
- {relativeDate(entry.createdAt)}
- </span>
- </div>
- );
-}