mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-07 00:08:54 -05:00
Discover: add a "Minor AI usage" AI filter (#104)
This commit is contained in:
@@ -33,7 +33,7 @@ import {
|
||||
AI_AREAS_DISCLOSURE,
|
||||
} from "./DiscoverFilters";
|
||||
import { baseGameLabel, baseRoms } from "@/data/baseRoms";
|
||||
import { aiFilterLabel, aiPresetOf, matchesAiFilter, NO_AI_FILTER, type AiFilter } from "@/utils/aiDisclosure";
|
||||
import { aiFilterActive, aiFilterLabel, aiPresetOf, matchesAiFilter, NO_AI_FILTER, type AiFilter } from "@/utils/aiDisclosure";
|
||||
|
||||
const SORT_OPTIONS: SelectOption[] = [
|
||||
{ value: "trending", label: "Trending", icon: MdWhatshot },
|
||||
@@ -354,7 +354,7 @@ export default function DiscoverBrowser({ catalog, generatedAt, initialState, ta
|
||||
const railRef = React.useRef<HTMLElement>(null);
|
||||
// The AI note under the results: phones open the sheet at "AI use", desktop points at it in the rail.
|
||||
const showAiFilter = () => (window.matchMedia("(min-width: 768px)").matches ? flashAiGroup(railRef.current) : openSheet(true));
|
||||
const aiNote = ai.hide.length > 0 && (
|
||||
const aiNote = aiFilterActive(ai) && (
|
||||
<button type="button" onClick={showAiFilter} className="text-link-hd inline-flex items-center gap-1 font-normal">
|
||||
<AiOffIcon className="h-3.5 w-3.5" />
|
||||
{aiFilterLabel(ai)}
|
||||
@@ -515,7 +515,7 @@ export default function DiscoverBrowser({ catalog, generatedAt, initialState, ta
|
||||
</button>
|
||||
)}
|
||||
{/* Clear keeps the saved AI filter, so offer it here when it may be what's hiding everything. */}
|
||||
{ai.hide.length > 0 && (
|
||||
{aiFilterActive(ai) && (
|
||||
<button type="button" className="text-link-hd text-sm" onClick={showAiFilter}>
|
||||
Change AI filter
|
||||
</button>
|
||||
|
||||
@@ -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, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
|
||||
import { AI_AREAS, 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. */
|
||||
@@ -61,7 +61,7 @@ export function countClearable(f: FilterState) {
|
||||
|
||||
/** Everything narrowing the results, for the phone's Filters badge. */
|
||||
export function countActive(f: FilterState) {
|
||||
return countClearable(f) + (f.ai.hide.length > 0 ? 1 : 0);
|
||||
return countClearable(f) + (aiFilterActive(f.ai) ? 1 : 0);
|
||||
}
|
||||
|
||||
export function toggleValue<T>(list: readonly T[], value: T) {
|
||||
@@ -122,8 +122,9 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
|
||||
const aiPreset = aiPresetOf(value.ai);
|
||||
const setAi = (ai: AiFilter) => onChange({ ...value, ai: normalizeAiFilter(ai) });
|
||||
// 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 });
|
||||
setAi({ 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
|
||||
@@ -240,13 +241,14 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
|
||||
<Disclosure
|
||||
id={AI_AREAS_DISCLOSURE}
|
||||
label="Choose areas"
|
||||
detail={value.ai.hide.length > 0 ? `${value.ai.hide.length} hidden` : ""}
|
||||
detail={value.ai.minor ? "Limit on all areas" : value.ai.hide.length > 0 ? `${value.ai.hide.length} hidden` : ""}
|
||||
expanded={open.has(AI_AREAS_DISCLOSURE)}
|
||||
onToggle={() => onToggleOpen(AI_AREAS_DISCLOSURE)}
|
||||
tall={tall}
|
||||
>
|
||||
{/* Indented to line up with the "Choose areas" label, past its chevron. */}
|
||||
<div className="pl-[22px]">
|
||||
{value.ai.minor && <p className="mb-2 text-xs leading-[1.4] text-text-2">Minor AI usage sets a limit across all areas. Pick areas to build your own filter instead.</p>}
|
||||
<p className="mb-0.5 text-xs font-semibold text-text-3">Hide hacks with AI in</p>
|
||||
{AI_AREAS.map((a) => (
|
||||
<React.Fragment key={a.key}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure";
|
||||
import { aiFilterActive, aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure";
|
||||
|
||||
const KEY = "hackdex:discover-ai";
|
||||
|
||||
@@ -16,7 +16,7 @@ export function readSavedAiFilter(): AiFilter {
|
||||
/** "Any" clears it. */
|
||||
export function saveAiFilter(filter: AiFilter) {
|
||||
try {
|
||||
if (filter.hide.length > 0) localStorage.setItem(KEY, aiFilterToken(filter));
|
||||
if (aiFilterActive(filter)) localStorage.setItem(KEY, aiFilterToken(filter));
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -72,35 +72,51 @@ 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. Hacks without a label pass every filter; most use no
|
||||
* AI, and reports catch the rest.
|
||||
* "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.
|
||||
*/
|
||||
export interface AiFilter {
|
||||
hide: readonly AiArea[];
|
||||
smallCode: boolean;
|
||||
minor: boolean;
|
||||
}
|
||||
|
||||
const MINOR_MAX_AREAS = 2;
|
||||
|
||||
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 } },
|
||||
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false } },
|
||||
{ value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false } },
|
||||
{
|
||||
value: "minor",
|
||||
label: "Minor AI usage",
|
||||
hint: `Up to ${MINOR_MAX_AREAS} areas, none above Some`,
|
||||
filter: { hide: [], smallCode: false, minor: true },
|
||||
},
|
||||
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: 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 },
|
||||
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false },
|
||||
},
|
||||
] as const satisfies readonly { value: string; label: string; hint?: string; filter: AiFilter }[];
|
||||
export type AiPreset = (typeof AI_PRESETS)[number]["value"];
|
||||
|
||||
export const NO_AI_FILTER: AiFilter = AI_PRESETS[0].filter;
|
||||
|
||||
/** Areas in label order, and smallCode only while code is hidden, so equal filters serialize the same. */
|
||||
/** 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 hide = AI_AREAS.map((a) => a.key).filter((k) => f.hide.includes(k));
|
||||
return { hide, smallCode: f.smallCode && hide.includes("code") };
|
||||
return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false };
|
||||
}
|
||||
|
||||
/** Whether the filter hides anything; "Any" doesn't. */
|
||||
export function aiFilterActive(f: AiFilter) {
|
||||
return f.minor || f.hide.length > 0;
|
||||
}
|
||||
|
||||
/** Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for code with small use allowed). */
|
||||
@@ -112,7 +128,7 @@ export function aiFilterToken(f: AiFilter): string {
|
||||
}
|
||||
|
||||
function sameAiFilter(a: AiFilter, b: AiFilter) {
|
||||
return a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]);
|
||||
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 {
|
||||
@@ -120,7 +136,7 @@ export function parseAiFilter(token: string | undefined): AiFilter {
|
||||
if (preset || !token) return preset?.filter ?? NO_AI_FILTER;
|
||||
const parts = token.split(".");
|
||||
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") });
|
||||
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false });
|
||||
}
|
||||
|
||||
/** The preset a filter matches, or "custom". */
|
||||
@@ -137,6 +153,10 @@ export function aiFilterLabel(f: AiFilter): string {
|
||||
|
||||
export function matchesAiFilter(levels: AiLevels | null, f: AiFilter) {
|
||||
if (!levels) return true;
|
||||
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);
|
||||
}
|
||||
return f.hide.every((k) => levels[k] === "none" || (k === "code" && f.smallCode && levels.code === "little"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user