mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-20 23:02:33 -05:00
Discover: let players hide hacks without an AI label, and show the November 2 deadline (#105)
This commit is contained in:
@@ -9,7 +9,7 @@ import { baseGameLabel, baseRoms, PLATFORM_NAMES, type Platform } from "@/data/b
|
||||
const RAIL_PLATFORMS: Platform[] = ["GBA", "GBC", "GB", "NDS"];
|
||||
import { DISCOVER_COMPLETION_STATUSES } from "@/app/discover/search-params";
|
||||
import type { DiscoverHack } from "@/types/discover";
|
||||
import { AI_AREAS, AI_PRESETS, aiFilterActive, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
|
||||
import { AI_AREAS, AI_DISCLOSURE_DEADLINE, AI_PRESETS, aiFilterActive, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
|
||||
import { useCloseOnDesktop, useDialog } from "@/hooks/useDialog";
|
||||
|
||||
/** The filterable part of the Discover URL state. The sheet edits a draft copy of this. */
|
||||
@@ -124,7 +124,7 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
|
||||
// Ticking code yourself allows small use by default; only the presets are strict about it.
|
||||
// Ticking an area leaves Minor, since it's a limit across all areas rather than a pick of them.
|
||||
const toggleAiArea = (k: AiArea) =>
|
||||
setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode, minor: false });
|
||||
setAi({ ...value.ai, hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode, minor: false });
|
||||
|
||||
const visibleGames = ROM_GAMES.map((g) => ({ ...g, dumps: g.dumps.filter((d) => !romQuery || g.label.toLowerCase().includes(romQuery) || d.label.toLowerCase().includes(romQuery)) })).filter((g) => g.dumps.length > 0);
|
||||
const visibleGroups = tagGroups
|
||||
@@ -228,9 +228,9 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
|
||||
className={rowH}
|
||||
label={p.label}
|
||||
hint={"hint" in p ? p.hint : undefined}
|
||||
count={counts.ai(p.filter)}
|
||||
count={counts.ai({ ...p.filter, disclosedOnly: value.ai.disclosedOnly })}
|
||||
checked={aiPreset === p.value}
|
||||
onChange={() => setAi(p.filter)}
|
||||
onChange={() => setAi({ ...p.filter, disclosedOnly: value.ai.disclosedOnly })}
|
||||
/>
|
||||
))}
|
||||
{/* Shows while the areas match no preset; ticking back to a preset's areas selects it again. */}
|
||||
@@ -266,6 +266,14 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
<Check
|
||||
className={`mt-1 ${rowH}`}
|
||||
label="Hide hacks without an AI label"
|
||||
hint={`Creators have until ${AI_DISCLOSURE_DEADLINE} to add missing labels, per the Terms.`}
|
||||
count={counts.ai({ ...value.ai, disclosedOnly: true })}
|
||||
checked={value.ai.disclosedOnly}
|
||||
onChange={() => setAi({ ...value.ai, disclosedOnly: !value.ai.disclosedOnly })}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group title="Tags">
|
||||
@@ -389,7 +397,8 @@ function Check({ label, hint, count, checked, onChange, className = "", ready =
|
||||
<label className={`group/check relative flex cursor-pointer select-none gap-2.5 rounded-md text-sm ${hint ? "items-start py-1.5" : "items-center"} ${className}`}>
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="peer sr-only" />
|
||||
<span className={`${BOX} ${hint ? "mt-px" : ""} ${ready ? "peer-checked:border-ready peer-checked:bg-ready" : "peer-checked:border-accent-deep peer-checked:bg-accent-deep"}`} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{/* A hint wraps, like Radio's; truncating would cut it to one line. */}
|
||||
<span className={`min-w-0 flex-1 ${hint ? "" : "truncate"}`}>
|
||||
{label}
|
||||
{hint && <small className="mt-0.5 block text-xs leading-[1.35] text-text-3">{hint}</small>}
|
||||
</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { formatRelativeDate } from "@/utils/format";
|
||||
import { MenuItem } from "@headlessui/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AI_DISCLOSURE_DEADLINE } from "@/utils/aiDisclosure";
|
||||
import { FaCircleCheck, FaDiscord, FaGithub, FaTwitter } from "react-icons/fa6";
|
||||
import { FiAlertTriangle, FiArrowUpRight, FiInfo, FiMail, FiUpload } from "react-icons/fi";
|
||||
import { RiArchiveStackFill } from "react-icons/ri";
|
||||
@@ -387,7 +388,22 @@ export default function HackDetailView({ metadata, downloads, canEdit, canUpload
|
||||
</RailGroup>
|
||||
|
||||
<RailGroup title="AI use">
|
||||
{editing ? <DraftAiLabel initial={metadata.ai} /> : metadata.ai ? <AiLabel disclosure={metadata.ai} /> : <AiLabelMissing />}
|
||||
{editing ? <DraftAiLabel initial={metadata.ai} /> : metadata.ai ? (
|
||||
<AiLabel disclosure={metadata.ai} />
|
||||
) : (
|
||||
// Archives may never get a label, so the deadline is only for creators' own uploads.
|
||||
<AiLabelMissing>
|
||||
{!isArchive && (
|
||||
<p className="mt-1.5 text-[12.5px]">
|
||||
The{" "}
|
||||
<Link href="/terms" prefetch={false} className="text-link-hd">
|
||||
Terms
|
||||
</Link>{" "}
|
||||
give creators until {AI_DISCLOSURE_DEADLINE} to fill it in.
|
||||
</p>
|
||||
)}
|
||||
</AiLabelMissing>
|
||||
)}
|
||||
</RailGroup>
|
||||
|
||||
{!isInformationalArchive && (
|
||||
|
||||
@@ -50,6 +50,9 @@ export interface AiDisclosure {
|
||||
|
||||
type AiRow = Pick<Tables<"hacks">, `ai_${AiArea}` | "ai_note" | "ai_disclosed_at">;
|
||||
|
||||
/** When hacks from before disclosure was required must have a label, per the Terms (Section 6). */
|
||||
export const AI_DISCLOSURE_DEADLINE = "November 2";
|
||||
|
||||
/** Columns to select wherever a hack's label is needed. */
|
||||
export const AI_SELECT = "ai_graphics,ai_music,ai_story,ai_translation,ai_events,ai_code,ai_note,ai_disclosed_at";
|
||||
|
||||
@@ -73,13 +76,15 @@ export function aiKind(levels: AiLevels): AiKind {
|
||||
/**
|
||||
* Discover's AI filter: hide hacks whose label shows AI in any of `hide`. With `smallCode`, code at
|
||||
* "A little" (a bug fix or a few) still passes. `minor` is a separate mode that ignores `hide`: AI in
|
||||
* at most MINOR_MAX_AREAS areas, none above Some. Hacks without a label pass every filter; most use
|
||||
* no AI, and reports catch the rest.
|
||||
* at most MINOR_MAX_AREAS areas, none above Some. Hacks without a label pass unless `disclosedOnly`;
|
||||
* most use no AI, and reports catch the rest. `disclosedOnly` sits beside the pick, so switching
|
||||
* presets keeps it.
|
||||
*/
|
||||
export interface AiFilter {
|
||||
hide: readonly AiArea[];
|
||||
smallCode: boolean;
|
||||
minor: boolean;
|
||||
disclosedOnly: boolean;
|
||||
}
|
||||
|
||||
const MINOR_MAX_AREAS = 2;
|
||||
@@ -88,19 +93,19 @@ const CONTENT_AREAS = AI_AREAS.map((a) => a.key).filter((k) => k !== "code");
|
||||
|
||||
/** Named filters, shown as radios; any other pick of areas is "Custom". */
|
||||
export const AI_PRESETS = [
|
||||
{ value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false } },
|
||||
{ value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false, disclosedOnly: false } },
|
||||
{
|
||||
value: "minor",
|
||||
label: "Minor AI usage",
|
||||
hint: `Up to ${MINOR_MAX_AREAS} areas, none above Some`,
|
||||
filter: { hide: [], smallCode: false, minor: true },
|
||||
filter: { hide: [], smallCode: false, minor: true, disclosedOnly: false },
|
||||
},
|
||||
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: false } },
|
||||
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: false, disclosedOnly: false } },
|
||||
{
|
||||
value: "none",
|
||||
label: "No direct AI usage",
|
||||
hint: "Nothing AI-generated was intentionally added by the creator",
|
||||
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false },
|
||||
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false, disclosedOnly: false },
|
||||
},
|
||||
] as const satisfies readonly { value: string; label: string; hint?: string; filter: AiFilter }[];
|
||||
export type AiPreset = (typeof AI_PRESETS)[number]["value"];
|
||||
@@ -109,50 +114,62 @@ export const NO_AI_FILTER: AiFilter = AI_PRESETS[0].filter;
|
||||
|
||||
/** Areas in label order, smallCode only while code is hidden, and no areas with minor, so equal filters serialize the same. */
|
||||
export function normalizeAiFilter(f: AiFilter): AiFilter {
|
||||
if (f.minor) return { hide: [], smallCode: false, minor: true };
|
||||
const { disclosedOnly } = f;
|
||||
if (f.minor) return { hide: [], smallCode: false, minor: true, disclosedOnly };
|
||||
const hide = AI_AREAS.map((a) => a.key).filter((k) => f.hide.includes(k));
|
||||
return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false };
|
||||
return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false, disclosedOnly };
|
||||
}
|
||||
|
||||
/** Whether the filter hides anything; "Any" doesn't. */
|
||||
/** Whether the filter hides anything; "Any" alone doesn't. */
|
||||
export function aiFilterActive(f: AiFilter) {
|
||||
return f.minor || f.hide.length > 0;
|
||||
return f.minor || f.hide.length > 0 || f.disclosedOnly;
|
||||
}
|
||||
|
||||
/** Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for code with small use allowed). */
|
||||
const DISCLOSED_TOKEN = "disclosed";
|
||||
|
||||
/**
|
||||
* Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for
|
||||
* code with small use allowed), then ".disclosed" with disclosedOnly.
|
||||
*/
|
||||
export function aiFilterToken(f: AiFilter): string {
|
||||
const n = normalizeAiFilter(f);
|
||||
const preset = AI_PRESETS.find((p) => sameAiFilter(p.filter, n));
|
||||
if (preset) return preset.value;
|
||||
return n.hide.map((k) => (k === "code" && n.smallCode ? "code-small" : k)).join(".");
|
||||
const preset = aiPresetOf(n);
|
||||
const pick = preset !== "custom" ? preset : n.hide.map((k) => (k === "code" && n.smallCode ? "code-small" : k)).join(".");
|
||||
return n.disclosedOnly ? `${pick}.${DISCLOSED_TOKEN}` : pick;
|
||||
}
|
||||
|
||||
function sameAiFilter(a: AiFilter, b: AiFilter) {
|
||||
/** Compares the pick only; disclosedOnly doesn't change which preset a filter is. */
|
||||
function samePick(a: AiFilter, b: AiFilter) {
|
||||
return a.minor === b.minor && a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]);
|
||||
}
|
||||
|
||||
export function parseAiFilter(token: string | undefined): AiFilter {
|
||||
const preset = AI_PRESETS.find((p) => p.value === token);
|
||||
if (preset || !token) return preset?.filter ?? NO_AI_FILTER;
|
||||
if (!token) return NO_AI_FILTER;
|
||||
const parts = token.split(".");
|
||||
const disclosedOnly = parts.includes(DISCLOSED_TOKEN);
|
||||
const preset = AI_PRESETS.find((p) => parts.includes(p.value));
|
||||
if (preset) return { ...preset.filter, disclosedOnly };
|
||||
const hide = AI_AREAS.map((a) => a.key).filter((k) => parts.includes(k) || (k === "code" && parts.includes("code-small")));
|
||||
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false });
|
||||
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false, disclosedOnly });
|
||||
}
|
||||
|
||||
/** The preset a filter matches, or "custom". */
|
||||
/** The preset a filter's pick matches, or "custom". */
|
||||
export function aiPresetOf(f: AiFilter): AiPreset | "custom" {
|
||||
const token = aiFilterToken(f);
|
||||
return AI_PRESETS.find((p) => p.value === token)?.value ?? "custom";
|
||||
const n = normalizeAiFilter(f);
|
||||
return AI_PRESETS.find((p) => samePick(p.filter, n))?.value ?? "custom";
|
||||
}
|
||||
|
||||
/** Discover's note on what the AI filter hides: the preset's name, or a general one for a custom pick. */
|
||||
export function aiFilterLabel(f: AiFilter): string {
|
||||
const preset = aiPresetOf(f);
|
||||
return preset === "custom" ? "Some AI usage hidden" : AI_PRESETS.find((p) => p.value === preset)!.label;
|
||||
if (preset === "custom") return "Some AI usage hidden";
|
||||
// "Any" is only active with disclosedOnly.
|
||||
if (preset === "any") return "Hacks without an AI label hidden";
|
||||
return AI_PRESETS.find((p) => p.value === preset)!.label;
|
||||
}
|
||||
|
||||
export function matchesAiFilter(levels: AiLevels | null, f: AiFilter) {
|
||||
if (!levels) return true;
|
||||
if (!levels) return !f.disclosedOnly;
|
||||
if (f.minor) {
|
||||
const used = AI_AREAS.filter((a) => levels[a.key] !== "none");
|
||||
return used.length <= MINOR_MAX_AREAS && used.every((a) => AI_LEVEL_STEP[levels[a.key]] <= AI_LEVEL_STEP.some);
|
||||
|
||||
Reference in New Issue
Block a user