Add report modal for individual hacks

This commit is contained in:
Jared Schoeny 2025-12-08 14:50:05 -10:00
parent ef55c0a40d
commit 59de8137cc
3 changed files with 526 additions and 68 deletions

View File

@ -3,6 +3,9 @@
import { createClient } from "@/utils/supabase/server";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { isInformationalArchiveHack } from "@/utils/hack";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { headers } from "next/headers";
import { validateEmail } from "@/utils/auth";
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
const supabase = await createClient();
@ -68,3 +71,125 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url:
}
}
export async function submitHackReport(data: {
slug: string;
reportType: "hateful" | "harassment" | "misleading" | "stolen";
details: string | null;
email: string | null;
isImpersonating: boolean | null;
}): Promise<{ error: string | null }> {
const supabase = await createClient();
// Validate hack exists
const { data: hack, error: hackError } = await supabase
.from("hacks")
.select("slug, title")
.eq("slug", data.slug)
.maybeSingle();
if (hackError || !hack) {
return { error: "Hack not found" };
}
// Validate email if provided (for stolen reports)
if (data.reportType === "stolen" && data.email) {
const emailLower = data.email.trim().toLowerCase();
const { error: emailError } = validateEmail(emailLower);
if (emailError) {
return { error: emailError };
}
}
// Validate required fields
if (data.reportType === "misleading" && !data.details?.trim()) {
return { error: "Details are required for misleading reports" };
}
if (data.reportType === "stolen") {
if (!data.email?.trim()) {
return { error: "Email is required for stolen hack reports" };
}
if (!data.details?.trim()) {
return { error: "Details are required for stolen hack reports" };
}
}
// Build hack URL
const hdrs = await headers();
const siteBase = process.env.NEXT_PUBLIC_SITE_URL ? process.env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "") : "";
const proto = siteBase ? "" : (hdrs.get("x-forwarded-proto") || "https");
const host = siteBase ? "" : (hdrs.get("host") || "");
const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : "");
const hackUrl = baseUrl ? `${baseUrl}/hack/${data.slug}` : `/hack/${data.slug}`;
// Format report type for display
const reportTypeLabels: Record<typeof data.reportType, string> = {
hateful: "Hateful Content",
harassment: "Harassment",
misleading: "Misleading",
stolen: "My Hack Was Stolen",
};
// Build Discord embed fields
const fields: Array<{ name: string; value: string; inline?: boolean }> = [
{
name: "Report Type",
value: reportTypeLabels[data.reportType],
inline: false,
},
{
name: "Hack",
value: `[${hack.title}](${hackUrl})`,
inline: false,
},
];
if (data.details) {
fields.push({
name: "Details",
value: data.details.length > 1000 ? data.details.substring(0, 1000) + "..." : data.details,
inline: false,
});
}
if (data.reportType === "stolen") {
if (data.email) {
fields.push({
name: "Contact Email",
value: data.email.trim().toLowerCase(),
inline: false,
});
}
if (data.isImpersonating !== null) {
fields.push({
name: "Is Uploader Impersonating?",
value: data.isImpersonating ? "Yes" : "No",
inline: true,
});
}
}
// Send Discord webhook
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
try {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
{
title: "Hack Report",
description: `A new report has been submitted for [${hack.title}](${hackUrl})`,
color: 0xff6b6b, // Red color for reports
fields,
footer: {
text: `Hack Slug: ${data.slug}`,
},
timestamp: new Date().toISOString(),
},
]);
} catch (error) {
console.error("Error sending Discord webhook:", error);
return { error: "Failed to submit report. Please try again later." };
}
}
return { error: null };
}

View File

@ -1,8 +1,9 @@
"use client";
import React from "react";
import React, { useState } from "react";
import { FiMoreVertical } from "react-icons/fi";
import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react";
import ReportModal from "@/components/Hack/ReportModal";
interface HackOptionsMenuProps {
slug: string;
@ -17,81 +18,88 @@ export default function HackOptionsMenu({
canUploadPatch,
children,
}: HackOptionsMenuProps) {
return (
<Menu as="div" className="relative">
<MenuButton
aria-label="More options"
title="Options"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
>
<FiMoreVertical size={18} />
</MenuButton>
const [showReportModal, setShowReportModal] = useState(false);
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-40 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="button"
onClick={async () => {
try {
const url = window.location.href;
const title = document?.title || "Check this out";
if (navigator.share) {
await navigator.share({ title, url });
} else {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
alert("Link copied to clipboard");
return (
<>
<Menu as="div" className="relative">
<MenuButton
aria-label="More options"
title="Options"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
>
<FiMoreVertical size={18} />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-40 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="button"
onClick={async () => {
try {
const url = window.location.href;
const title = document?.title || "Check this out";
if (navigator.share) {
await navigator.share({ title, url });
} else {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
alert("Link copied to clipboard");
}
}
} catch (e) {
// Ignore if user cancels share; otherwise log
if (!(e instanceof Error) || e.name !== "AbortError") {
console.error(e);
}
}
} catch (e) {
// Ignore if user cancels share; otherwise log
if (!(e instanceof Error) || e.name !== "AbortError") {
console.error(e);
}
}
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Share
</MenuItem>
<MenuItem
as="button"
onClick={() => {
// TODO: Implement report
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Report
</MenuItem>
{canEdit && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuItem
as="a"
href={`/hack/${slug}/stats`}
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Stats
Share
</MenuItem>
<MenuItem
as="a"
href={`/hack/${slug}/edit`}
as="button"
onClick={() => {
setShowReportModal(true);
}}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Edit
Report
</MenuItem>
</>}
{canUploadPatch && <>
<MenuItem
as="a"
href={`/hack/${slug}/edit/patch`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Upload new version
</MenuItem>
</>}
{children && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
{children}
</>}
</MenuItems>
</Menu>
{canEdit && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
<MenuItem
as="a"
href={`/hack/${slug}/stats`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Stats
</MenuItem>
<MenuItem
as="a"
href={`/hack/${slug}/edit`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Edit
</MenuItem>
</>}
{canUploadPatch && <>
<MenuItem
as="a"
href={`/hack/${slug}/edit/patch`}
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
Upload new version
</MenuItem>
</>}
{children && <>
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
{children}
</>}
</MenuItems>
</Menu>
{showReportModal && (
<ReportModal slug={slug} onClose={() => setShowReportModal(false)} />
)}
</>
);
}

View File

@ -0,0 +1,325 @@
"use client";
import React, { useEffect, useState } from "react";
import { FiX } from "react-icons/fi";
import { FaCircleCheck } from "react-icons/fa6";
import { submitHackReport } from "@/app/hack/[slug]/actions";
type ReportType = "hateful" | "harassment" | "misleading" | "stolen";
interface ReportModalProps {
slug: string;
onClose: () => void;
}
const ReportModal: React.FC<ReportModalProps> = ({ slug, onClose }) => {
const [currentPage, setCurrentPage] = useState<"select" | "details" | "success">("select");
const [reportType, setReportType] = useState<ReportType | null>(null);
const [details, setDetails] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [isImpersonating, setIsImpersonating] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollBarWidth = window.innerWidth - html.clientWidth;
html.style.overflow = "hidden";
body.style.overflow = "hidden";
if (scrollBarWidth > 0) {
body.style.paddingRight = `${scrollBarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, []);
const canSubmit = () => {
if (!reportType) return false;
if (reportType === "stolen") {
// Stolen requires email and details
if (!email.trim() || !details.trim()) return false;
// Basic email validation (matches server-side validation pattern)
const emailRegex = /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/;
if (!emailRegex.test(email.trim().toLowerCase())) return false;
return true;
}
if (reportType === "misleading") {
// Misleading requires details
return details.trim().length > 0;
}
// Hateful and Harassment are optional, so can always submit
return true;
};
const handleSubmit = async () => {
if (!canSubmit() || !reportType) return;
setIsSubmitting(true);
setError(null);
try {
const result = await submitHackReport({
slug,
reportType,
details: details.trim() || null,
email: reportType === "stolen" ? email.trim() : null,
isImpersonating: reportType === "stolen" ? isImpersonating : null,
});
if (result.error) {
setError(result.error);
setIsSubmitting(false);
} else {
setCurrentPage("success");
setIsSubmitting(false);
}
} catch (e) {
setError("Failed to submit report. Please try again.");
setIsSubmitting(false);
}
};
const renderSelectPage = () => (
<div className="flex flex-col gap-8 sm:gap-4">
<div className="mb-2">
<div className="text-xl font-semibold">Report Hack</div>
<p className="mt-1 text-sm text-foreground/80">
Please select the reason for reporting this hack.
</p>
</div>
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => {
setReportType("hateful");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Hateful content
</button>
<button
type="button"
onClick={() => {
setReportType("harassment");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Harassment
</button>
<button
type="button"
onClick={() => {
setReportType("misleading");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
Misleading
</button>
<button
type="button"
onClick={() => {
setReportType("stolen");
setCurrentPage("details");
}}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
>
My hack was stolen
</button>
</div>
</div>
);
const renderSuccessPage = () => (
<div className="flex flex-col gap-6 sm:gap-4">
<div className="mb-2">
<div className="text-xl font-semibold">Report Submitted</div>
<p className="mt-1 text-sm text-foreground/80">
Thank you for your report. We will review it and take appropriate action.
</p>
</div>
<div className="p-4 rounded-md bg-green-500/10 border border-green-500/20">
<p className="text-green-500 font-semibold flex items-center justify-center gap-2">
<FaCircleCheck size={18} />
Report successfully sent!
</p>
</div>
<div className="flex flex-col gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center h-14 sm:h-11 w-full text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] enabled:hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
Done
</button>
</div>
</div>
);
const renderDetailsPage = () => {
const isStolen = reportType === "stolen";
const isMisleading = reportType === "misleading";
const requiresDetails = isStolen || isMisleading;
const isOptional = reportType === "hateful" || reportType === "harassment";
return (
<div className="flex flex-col gap-6 sm:gap-4">
<div className="mb-2">
<div className="text-xl font-semibold">
{reportType === "hateful" && "Hateful Content"}
{reportType === "harassment" && "Harassment"}
{reportType === "misleading" && "Misleading"}
{reportType === "stolen" && "My Hack Was Stolen"}
</div>
<p className="mt-1 text-sm text-foreground/80">
{isStolen
? "Please provide your contact information and details about the stolen hack."
: isOptional
? "Please provide additional details (optional)."
: "Please provide additional details."}
</p>
</div>
{isStolen && (
<>
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isImpersonating}
onChange={(e) => setIsImpersonating(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm font-semibold">Is the uploader impersonating you?</span>
</label>
</div>
<div>
<label className="text-sm font-semibold mb-2 block">
Contact Email <span className="text-red-500">*</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
</div>
</>
)}
<div>
<label className="text-sm font-semibold mb-2 block">
Additional Details {requiresDetails && <span className="text-red-500">*</span>}
{isOptional && <span className="text-foreground/60 text-xs ml-1">(optional)</span>}
</label>
<textarea
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder={
isStolen
? "Please provide important context, proof of ownership, and any other relevant information..."
: "Please provide additional context..."
}
rows={6}
className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] resize-none"
/>
</div>
{isStolen && (
<div className="p-4 rounded-md bg-[var(--surface-2)] border border-[var(--border)]">
<p className="text-sm text-foreground/90">
<strong>Note:</strong> We will reach out to you via email. Please ensure you have sufficient proof that you are the original creator of this hack.
</p>
</div>
)}
{error && (
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="flex flex-col gap-3 pt-2">
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit() || isSubmitting}
className="inline-flex items-center justify-center h-14 sm:h-11 w-full text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] enabled:hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSubmitting ? "Submitting..." : "Submit Report"}
</button>
<button
type="button"
onClick={() => {
setCurrentPage("select");
setReportType(null);
setDetails("");
setEmail("");
setIsImpersonating(false);
setError(null);
}}
disabled={isSubmitting}
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)] disabled:opacity-50 disabled:cursor-not-allowed"
>
Back
</button>
</div>
</div>
);
};
return (
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={
currentPage === "select"
? "Select report type"
: currentPage === "success"
? "Report submitted"
: "Report details"
}
className="relative z-[101] mb-16 card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md max-h-[85vh] overflow-y-auto w-full rounded-lg"
>
<button
type="button"
onClick={onClose}
aria-label="Close report modal"
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<FiX size={20} />
</button>
{currentPage === "select"
? renderSelectPage()
: currentPage === "success"
? renderSuccessPage()
: renderDetailsPage()}
</div>
</div>
);
};
export default ReportModal;