feat: improve form results and harden secrets
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
@@ -12,7 +19,15 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuGroup
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,6 +42,11 @@ import { stripHtml } from "@/lib/utils";
|
||||
import { HtmlDisplay } from "@/components/html-display";
|
||||
import { formatStoredAnswer } from "@/lib/form-answer-values";
|
||||
import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake";
|
||||
import { updateSubmissionAdminNote } from "@/app/admin/form/actions";
|
||||
import {
|
||||
useDebouncedAutoSave,
|
||||
type AutoSaveStatus,
|
||||
} from "@/hooks/use-debounced-autosave";
|
||||
import {
|
||||
DiscordProfileDropdown,
|
||||
type DiscordMemberProfile,
|
||||
@@ -42,10 +62,12 @@ interface Question {
|
||||
interface SubmissionDetailClientProps {
|
||||
submission: {
|
||||
id: string;
|
||||
formId: string;
|
||||
userName: string | null;
|
||||
userDiscordId: string | null;
|
||||
userProfile: DiscordMemberProfile | null;
|
||||
submittedAt: Date;
|
||||
adminNote: string | null;
|
||||
editHistory?: { editedAt: string; oldAnswers: Record<string, string | { value: string; imageCdnId: string | null }> }[] | null;
|
||||
};
|
||||
questions: Question[];
|
||||
@@ -58,6 +80,7 @@ export default function SubmissionDetailClient({
|
||||
answers,
|
||||
}: SubmissionDetailClientProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [adminNote, setAdminNote] = useState(submission.adminNote ?? "");
|
||||
|
||||
const handleCopy = (text: string, id: string) => {
|
||||
if (!text) return;
|
||||
@@ -72,7 +95,19 @@ export default function SubmissionDetailClient({
|
||||
|
||||
const editHistory = Array.isArray(submission.editHistory) ? submission.editHistory : [];
|
||||
|
||||
|
||||
const saveAdminNote = useCallback(
|
||||
() =>
|
||||
updateSubmissionAdminNote(
|
||||
submission.formId,
|
||||
submission.id,
|
||||
adminNote
|
||||
).then(() => undefined),
|
||||
[adminNote, submission.formId, submission.id]
|
||||
);
|
||||
const noteAutoSave = useDebouncedAutoSave({
|
||||
save: saveAdminNote,
|
||||
delay: 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -120,6 +155,42 @@ export default function SubmissionDetailClient({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Admin note</CardTitle>
|
||||
<CardDescription>
|
||||
Private context for administrators. Respondents cannot see this note.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={noteAutoSave.status === "error"}>
|
||||
<FieldLabel htmlFor="admin-note">Note</FieldLabel>
|
||||
<Textarea
|
||||
id="admin-note"
|
||||
value={adminNote}
|
||||
onChange={(event) => {
|
||||
setAdminNote(event.target.value);
|
||||
noteAutoSave.schedule();
|
||||
}}
|
||||
placeholder="Add a private note about this submission..."
|
||||
maxLength={10_000}
|
||||
aria-invalid={noteAutoSave.status === "error"}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Autosaves after you stop typing. Only administrators can view
|
||||
this note.
|
||||
</FieldDescription>
|
||||
<SaveStatusIndicator
|
||||
status={noteAutoSave.status}
|
||||
error={noteAutoSave.error}
|
||||
onRetry={noteAutoSave.retry}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Answers List */}
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => {
|
||||
@@ -289,6 +360,54 @@ export default function SubmissionDetailClient({
|
||||
);
|
||||
}
|
||||
|
||||
function SaveStatusIndicator({
|
||||
status,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
status: AutoSaveStatus;
|
||||
error: string | null;
|
||||
onRetry: () => Promise<void>;
|
||||
}) {
|
||||
if (status === "clean") return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{status === "dirty" && "Unsaved"}
|
||||
{status === "saving" && (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Saving
|
||||
</>
|
||||
)}
|
||||
{status === "saved" && (
|
||||
<>
|
||||
<Check />
|
||||
Saved
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<AlertCircle />
|
||||
<span>{error || "Could not save the note."}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-1"
|
||||
onClick={() => void onRetry().catch(() => undefined)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswerImagePreview({ src, label }: { src: string; label: string }) {
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export default async function SubmissionDetailPage({
|
||||
</div>
|
||||
|
||||
<SubmissionDetailClient
|
||||
key={submission.id}
|
||||
submission={{
|
||||
...submission,
|
||||
userProfile: respondentProfile,
|
||||
|
||||
@@ -142,6 +142,7 @@ export default async function ResultsListPage({
|
||||
<TableHead>Respondent</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Admin note</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -181,6 +182,17 @@ export default async function ResultsListPage({
|
||||
{edited ? "Edited" : "Original"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-80">
|
||||
{submission.adminNote ? (
|
||||
<p className="line-clamp-2 whitespace-pre-wrap text-sm">
|
||||
{submission.adminNote}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-sm italic text-muted-foreground">
|
||||
No note
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="relative z-10 text-right">
|
||||
<DeleteSubmissionButton id={submission.id} compact />
|
||||
</TableCell>
|
||||
@@ -222,7 +234,21 @@ export default async function ResultsListPage({
|
||||
{formatDate(submission.submittedAt)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-end">
|
||||
<CardContent className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Admin note
|
||||
</p>
|
||||
{submission.adminNote ? (
|
||||
<p className="line-clamp-3 whitespace-pre-wrap text-sm">
|
||||
{submission.adminNote}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No note
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<DeleteSubmissionButton id={submission.id} compact />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { forms } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { forms, submissions } from "@/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
@@ -78,6 +78,44 @@ export async function updateForm(id: string, data: { title?: string; description
|
||||
revalidatePath("/admin/form");
|
||||
}
|
||||
|
||||
export async function updateSubmissionAdminNote(
|
||||
formId: string,
|
||||
submissionId: string,
|
||||
note: string
|
||||
) {
|
||||
await requireAdmin();
|
||||
|
||||
if (
|
||||
typeof formId !== "string" ||
|
||||
!formId ||
|
||||
typeof submissionId !== "string" ||
|
||||
!submissionId ||
|
||||
typeof note !== "string" ||
|
||||
note.length > 10_000
|
||||
) {
|
||||
throw new Error("Invalid admin note.");
|
||||
}
|
||||
|
||||
const [updatedSubmission] = await db
|
||||
.update(submissions)
|
||||
.set({ adminNote: note.trim() || null })
|
||||
.where(
|
||||
and(
|
||||
eq(submissions.id, submissionId),
|
||||
eq(submissions.formId, formId)
|
||||
)
|
||||
)
|
||||
.returning({ id: submissions.id });
|
||||
|
||||
if (!updatedSubmission) {
|
||||
throw new Error("Submission not found.");
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/form/${formId}/result`);
|
||||
revalidatePath(`/admin/form/${formId}/result/${submissionId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function testDiscordWebhook(url: string, content: string) {
|
||||
await requireAdmin();
|
||||
const trimmedUrl = url.trim();
|
||||
|
||||
@@ -50,6 +50,10 @@ export default async function FormsSubmitPage(
|
||||
where: (s, { eq, and }) =>
|
||||
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
|
||||
orderBy: (s, { desc }) => [desc(s.submittedAt)],
|
||||
columns: {
|
||||
id: true,
|
||||
submittedAt: true,
|
||||
},
|
||||
}),
|
||||
getFormSubmissionStats([form.id], discordId),
|
||||
]);
|
||||
|
||||
@@ -5,11 +5,14 @@ import { getFollowerCounts } from "@/lib/followers";
|
||||
import { LinkCard } from "@/components/LinkCard";
|
||||
import { getRedisClient } from "@/lib/redis";
|
||||
import { headers } from "next/headers";
|
||||
import { connection } from "next/server";
|
||||
import { Users } from "lucide-react";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import Background from "@/public/background/space.webp";
|
||||
|
||||
export default async function Home() {
|
||||
await connection();
|
||||
|
||||
const { counts } = await getFollowerCounts();
|
||||
|
||||
const headersList = await headers();
|
||||
|
||||
Reference in New Issue
Block a user