From 127b4e318f3778e0b8dc9b60e93c1b76326fed1c Mon Sep 17 00:00:00 2001 From: Nikolay Golovin Date: Sun, 6 Sep 2026 14:07:54 +0300 Subject: [PATCH] feat(editor): favorites strip and tested catalog filters (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the remaining #29 “favoritos o recientes” gap on top of the merged Stage 4 palette: star favorites with localStorage, FavoritesStrip re-select, and pure catalogFilter helpers covered by node:test. --- docs/licensing-notes.md | 6 + frontend/app/construccion/page.tsx | 2 + frontend/components/editor/FavoritesStrip.tsx | 107 +++++++++++++ frontend/components/editor/NpcsBrowser.tsx | 119 +++++++++----- frontend/components/editor/ObjectsBrowser.tsx | 148 ++++++++++-------- frontend/components/editor/TerrainPalette.tsx | 113 ++++++++----- frontend/lib/editor/catalogFilter.test.ts | 134 ++++++++++++++++ frontend/lib/editor/catalogFilter.ts | 133 ++++++++++++++++ frontend/lib/editor/editorStore.tsx | 63 ++++++++ 9 files changed, 690 insertions(+), 135 deletions(-) create mode 100644 frontend/components/editor/FavoritesStrip.tsx create mode 100644 frontend/lib/editor/catalogFilter.test.ts create mode 100644 frontend/lib/editor/catalogFilter.ts diff --git a/docs/licensing-notes.md b/docs/licensing-notes.md index 0aa7191c..547b14f3 100644 --- a/docs/licensing-notes.md +++ b/docs/licensing-notes.md @@ -30,3 +30,9 @@ Cualquier coincidencia en nombres de archivos o de componentes es generica No se agregaron dependencias nuevas; se usan las ya presentes (pixi.js, next, react) con sus licencias existentes. + +## Follow-up: favorites + catalog helpers + +The `★` favorites strip and `frontend/lib/editor/catalogFilter.ts` helpers added +after Stage 4 (#29) are original OpenAO code. They do not reuse any source from +AO-object-editor; only the product requirement ("favoritos o recientes") is shared. diff --git a/frontend/app/construccion/page.tsx b/frontend/app/construccion/page.tsx index 399d29f0..2cf49983 100644 --- a/frontend/app/construccion/page.tsx +++ b/frontend/app/construccion/page.tsx @@ -7,6 +7,7 @@ import { EditorStoreProvider, useEditorStore } from "../../lib/editor/editorStor import { useGameDataAdmin } from "../../lib/editor/useGameDataAdmin"; import EditorToolbar from "../../components/editor/EditorToolbar"; import RecentsStrip from "../../components/editor/RecentsStrip"; +import FavoritesStrip from "../../components/editor/FavoritesStrip"; import TerrainPalette from "../../components/editor/TerrainPalette"; import ObjectsBrowser from "../../components/editor/ObjectsBrowser"; import NpcsBrowser from "../../components/editor/NpcsBrowser"; @@ -99,6 +100,7 @@ function ConstruccionEditor() { + ); diff --git a/frontend/components/editor/FavoritesStrip.tsx b/frontend/components/editor/FavoritesStrip.tsx new file mode 100644 index 00000000..87ff71c0 --- /dev/null +++ b/frontend/components/editor/FavoritesStrip.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { findTerrainBrush, useEditorStore } from "../../lib/editor/editorStore"; +import GraphicPreview from "./GraphicPreview"; + +const KIND_LABELS: Record = { + terrain: "Terreno", + object: "Objeto", + npc: "NPC", +}; + +/** + * Tira de favoritos del editor (#29). Persiste en localStorage y permite + * re-seleccionar herramientas sin buscar de nuevo en el catalogo. + */ +export default function FavoritesStrip() { + const { + favorites, + objects, + npcs, + terrain, + setTool, + toggleFavorite, + } = useEditorStore(); + + if (favorites.length === 0) { + return ( +
+ Marca con ★ los objetos, NPCs o tiles que uses seguido; quedan + aca como favoritos entre sesiones. +
+ ); + } + + return ( +
+ + Favoritos + + {favorites.map((entry) => { + const matchedObject = + entry.kind === "object" + ? objects.find((object) => object.id === entry.id) + : undefined; + const matchedNpc = + entry.kind === "npc" + ? npcs.find((npc) => npc.id === entry.id) + : undefined; + const matchedBrush = + entry.kind === "terrain" + ? findTerrainBrush(terrain, entry.id) + : null; + const isAvailable = + entry.kind === "terrain" + ? matchedBrush !== null + : entry.kind === "object" + ? matchedObject !== undefined + : matchedNpc !== undefined; + + return ( +
+ + +
+ ); + })} +
+ ); +} diff --git a/frontend/components/editor/NpcsBrowser.tsx b/frontend/components/editor/NpcsBrowser.tsx index 7b0f1fce..55083b59 100644 --- a/frontend/components/editor/NpcsBrowser.tsx +++ b/frontend/components/editor/NpcsBrowser.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type MouseEvent } from "react"; import type { BodiesDB, HeadsDB } from "../../types/game"; +import { filterByNameOrId } from "../../lib/editor/catalogFilter"; import type { EditorNpc } from "../../lib/editor/editorApi"; import { useEditorStore } from "../../lib/editor/editorStore"; import { @@ -23,12 +24,17 @@ const ITEM_HEIGHT = 76; * contexto WebGL- por fila, y el navegador mantiene vivos apenas unos quince. */ export default function NpcsBrowser() { - const { npcs, tool, setTool, addRecent } = useEditorStore(); + const { + npcs, + tool, + setTool, + addRecent, + toggleFavorite, + isFavorite, + } = useEditorStore(); const [search, setSearch] = useState(""); const [bodiesDB, setBodiesDB] = useState(null); const [headsDB, setHeadsDB] = useState(null); - const normalizedSearch = search.trim().toLowerCase(); - useEffect(() => { let cancelled = false; @@ -48,17 +54,10 @@ export default function NpcsBrowser() { }; }, []); - const filteredNpcs = useMemo(() => { - if (!normalizedSearch) { - return npcs; - } - - return npcs.filter( - (entry) => - entry.name.toLowerCase().includes(normalizedSearch) || - String(entry.id).includes(normalizedSearch), - ); - }, [normalizedSearch, npcs]); + const filteredNpcs = useMemo( + () => filterByNameOrId(npcs, search), + [npcs, search], + ); const selectedId = tool?.kind === "npc" ? tool.npc.id : null; @@ -96,6 +95,20 @@ export default function NpcsBrowser() { } }; + const handleToggleFavorite = ( + event: MouseEvent, + entry: EditorNpc, + grhIndex: number, + ) => { + event.stopPropagation(); + toggleFavorite({ + kind: "npc", + id: entry.id, + grhIndex, + name: entry.name, + }); + }; + return (
entry.id} renderItem={(entry) => { const isSelected = selectedId === entry.id; + const previewGrh = resolveCharacterThumbnailGrh( + bodiesDB, + headsDB, + entry.idBody, + entry.idHead, + ); + const favorited = isFavorite("npc", entry.id); return ( - + + +
); }} itemHeight={ITEM_HEIGHT} diff --git a/frontend/components/editor/ObjectsBrowser.tsx b/frontend/components/editor/ObjectsBrowser.tsx index e2107ffb..8c7f6ea1 100644 --- a/frontend/components/editor/ObjectsBrowser.tsx +++ b/frontend/components/editor/ObjectsBrowser.tsx @@ -1,7 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type MouseEvent } from "react"; import { getObjectType, OBJECT_TYPES } from "../../data/objectTypes"; +import { + countByObjType, + filterObjects, +} from "../../lib/editor/catalogFilter"; import type { EditorObject } from "../../lib/editor/editorApi"; import { useEditorStore } from "../../lib/editor/editorStore"; import GraphicPreview from "./GraphicPreview"; @@ -10,49 +14,35 @@ import VirtualizedList from "./VirtualizedList"; const ITEM_HEIGHT = 56; /** - * Catalogo de objetos del juego con busqueda y filtro por tipo. + * Catalogo de objetos del juego con busqueda, filtro por tipo y favoritos. * * Al seleccionar un objeto se activa la herramienta de colocacion y se suma * al historial de recientes. */ export default function ObjectsBrowser() { - const { objects, tool, setTool, addRecent } = useEditorStore(); + const { + objects, + tool, + setTool, + addRecent, + toggleFavorite, + isFavorite, + } = useEditorStore(); const [search, setSearch] = useState(""); const [objTypeFilter, setObjTypeFilter] = useState(null); - const normalizedSearch = search.trim().toLowerCase(); - // Un recorrido por tipo y no un filter por chip: son 37 chips sobre mil - // objetos, y se recalculaba entero en cada tecla de la busqueda. - const countsByType = useMemo(() => { - const counts = new Map(); + const countsByType = useMemo(() => countByObjType(objects), [objects]); - for (const entry of objects) { - counts.set(entry.objType, (counts.get(entry.objType) ?? 0) + 1); - } - - return counts; - }, [objects]); - - const filteredObjects = useMemo(() => { - let result = objects; - - if (objTypeFilter !== null) { - result = result.filter((entry) => entry.objType === objTypeFilter); - } - - if (normalizedSearch) { - result = result.filter( - (entry) => - entry.name.toLowerCase().includes(normalizedSearch) || - String(entry.id).includes(normalizedSearch), - ); - } - - return result; - }, [normalizedSearch, objTypeFilter, objects]); + const filteredObjects = useMemo( + () => + filterObjects(objects, { + query: search, + objType: objTypeFilter, + }), + [objTypeFilter, objects, search], + ); - const selectedId = - tool?.kind === "object" ? tool.object.id : null; + const selectedId = tool?.kind === "object" ? tool.object.id : null; const handleSelect = (entry: EditorObject) => { setTool({ kind: "object", object: entry }); @@ -64,6 +54,19 @@ export default function ObjectsBrowser() { }); }; + const handleToggleFavorite = ( + event: MouseEvent, + entry: EditorObject, + ) => { + event.stopPropagation(); + toggleFavorite({ + kind: "object", + id: entry.id, + grhIndex: entry.grhIndex, + name: entry.name, + }); + }; + return (
{ const type = getObjectType(entry.objType); const isSelected = selectedId === entry.id; + const favorited = isFavorite("object", entry.id); return ( - + + +
); }} itemHeight={ITEM_HEIGHT} @@ -171,4 +197,4 @@ export default function ObjectsBrowser() { )} ); -} \ No newline at end of file +} diff --git a/frontend/components/editor/TerrainPalette.tsx b/frontend/components/editor/TerrainPalette.tsx index b441b61e..f2b4156d 100644 --- a/frontend/components/editor/TerrainPalette.tsx +++ b/frontend/components/editor/TerrainPalette.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState, type MouseEvent } from "react"; import { createTerrainBrush, createUploadedGraphicBrush, @@ -27,8 +27,15 @@ type PaletteTab = "terrain" | "uploaded"; * independientes, no una sola con dos encabezados pegajosos. */ export default function TerrainPalette() { - const { terrain, tool, setTool, addRecent, refreshMapData } = - useEditorStore(); + const { + terrain, + tool, + setTool, + addRecent, + refreshMapData, + toggleFavorite, + isFavorite, + } = useEditorStore(); const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); @@ -59,6 +66,19 @@ export default function TerrainPalette() { }); }; + const handleToggleFavorite = ( + event: MouseEvent, + brush: TerrainBrush, + ) => { + event.stopPropagation(); + toggleFavorite({ + kind: "terrain", + id: brush.paletteId, + grhIndex: brush.grhIndex, + name: `Tile ${brush.paletteId}`, + }); + }; + const handleFile = async (file: File) => { setIsUploading(true); setUploadError(null); @@ -156,41 +176,64 @@ export default function TerrainPalette() { itemHeight={CELL_HEIGHT} className="min-h-0 flex-1 pr-1" getItemKey={(brush) => brush.paletteId} - renderItem={(brush) => ( -
- + -
- )} + ★ + + + ); + }} /> )} diff --git a/frontend/lib/editor/catalogFilter.test.ts b/frontend/lib/editor/catalogFilter.test.ts new file mode 100644 index 00000000..720735af --- /dev/null +++ b/frontend/lib/editor/catalogFilter.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + countByObjType, + favoriteKey, + filterByNameOrId, + filterObjects, + isFavorite, + parseFavorites, + toggleFavoriteEntry, + type CatalogFavorite, + type TypedCatalogEntry, +} from "./catalogFilter.ts"; + +const SAMPLE: TypedCatalogEntry[] = [ + { id: 1, name: "Manzana Roja", objType: 1 }, + { id: 2, name: "Espada Corta", objType: 2 }, + { id: 10, name: "Manzana Verde", objType: 1 }, + { id: 42, name: "Puerta de Roble", objType: 6 }, + { id: 100, name: "Pocion Roja", objType: 11 }, +]; + +describe("filterByNameOrId", () => { + it("returns all entries for empty query", () => { + assert.equal(filterByNameOrId(SAMPLE, "").length, SAMPLE.length); + assert.equal(filterByNameOrId(SAMPLE, " ").length, SAMPLE.length); + }); + + it("matches name substring case-insensitively", () => { + const hits = filterByNameOrId(SAMPLE, "manzana"); + assert.deepEqual( + hits.map((e) => e.id), + [1, 10], + ); + }); + + it("matches by id substring", () => { + const hits = filterByNameOrId(SAMPLE, "42"); + assert.equal(hits.length, 1); + assert.equal(hits[0].id, 42); + }); +}); + +describe("filterObjects", () => { + it("filters by type then by query", () => { + const hits = filterObjects(SAMPLE, { objType: 1, query: "verde" }); + assert.equal(hits.length, 1); + assert.equal(hits[0].id, 10); + }); + + it("keeps all types when objType is null", () => { + const hits = filterObjects(SAMPLE, { objType: null, query: "roja" }); + assert.deepEqual( + hits.map((e) => e.id), + [1, 100], + ); + }); +}); + +describe("countByObjType", () => { + it("counts chips for full catalog", () => { + const counts = countByObjType(SAMPLE); + assert.equal(counts.get(1), 2); + assert.equal(counts.get(2), 1); + assert.equal(counts.get(6), 1); + assert.equal(counts.get(11), 1); + assert.equal(counts.get(99), undefined); + }); +}); + +describe("favorites helpers", () => { + const apple: CatalogFavorite = { + kind: "object", + id: 1, + grhIndex: 500, + name: "Manzana Roja", + }; + const sword: CatalogFavorite = { + kind: "object", + id: 2, + grhIndex: 501, + name: "Espada Corta", + }; + + it("builds stable keys", () => { + assert.equal(favoriteKey("object", 1), "object:1"); + assert.equal(favoriteKey("npc", 9), "npc:9"); + }); + + it("toggles add then remove", () => { + const added = toggleFavoriteEntry([], apple); + assert.equal(added.length, 1); + assert.equal(isFavorite(added, "object", 1), true); + + const removed = toggleFavoriteEntry(added, apple); + assert.equal(removed.length, 0); + assert.equal(isFavorite(removed, "object", 1), false); + }); + + it("prepends newest and respects limit", () => { + const once = toggleFavoriteEntry([], apple); + const twice = toggleFavoriteEntry(once, sword); + assert.deepEqual( + twice.map((e) => e.id), + [2, 1], + ); + + const capped = toggleFavoriteEntry( + Array.from({ length: 24 }, (_, i) => ({ + kind: "object" as const, + id: i + 10, + grhIndex: i, + name: `Item ${i}`, + })), + apple, + 24, + ); + assert.equal(capped.length, 24); + assert.equal(capped[0].id, 1); + }); + + it("parseFavorites rejects garbage", () => { + assert.deepEqual(parseFavorites(null), []); + assert.deepEqual(parseFavorites("nope"), []); + assert.deepEqual( + parseFavorites([{ kind: "object", id: 1, grhIndex: 1, name: "ok" }]), + [{ kind: "object", id: 1, grhIndex: 1, name: "ok" }], + ); + assert.deepEqual( + parseFavorites([{ kind: "wizard", id: 1, grhIndex: 1, name: "x" }]), + [], + ); + }); +}); diff --git a/frontend/lib/editor/catalogFilter.ts b/frontend/lib/editor/catalogFilter.ts new file mode 100644 index 00000000..811a177d --- /dev/null +++ b/frontend/lib/editor/catalogFilter.ts @@ -0,0 +1,133 @@ +/** + * Pure helpers for the construction-mode content browsers (#29). + * + * Kept free of React / localStorage so the filter + favorites math can be + * unit-tested without mounting the editor. + */ + +export type CatalogKind = "terrain" | "object" | "npc"; + +export type CatalogFavorite = { + kind: CatalogKind; + id: number; + grhIndex: number; + name: string; +}; + +export type NamedCatalogEntry = { + id: number; + name: string; +}; + +export type TypedCatalogEntry = NamedCatalogEntry & { + objType: number; +}; + +/** + * Filter a named catalog by free-text query (name substring or exact id). + */ +export function filterByNameOrId( + entries: readonly T[], + query: string, +): T[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return [...entries]; + } + + return entries.filter( + (entry) => + entry.name.toLowerCase().includes(normalized) || + String(entry.id).includes(normalized), + ); +} + +/** + * Filter objects by optional type chip + free-text query. + * Generic so callers keep fields like `grhIndex` from `EditorObject`. + */ +export function filterObjects( + entries: readonly T[], + opts: { query?: string; objType?: number | null } = {}, +): T[] { + const objType = opts.objType ?? null; + const typed = + objType === null + ? [...entries] + : entries.filter((entry) => entry.objType === objType); + + return filterByNameOrId(typed, opts.query ?? ""); +} + +/** + * Count how many objects fall under each objType (for chip badges). + */ +export function countByObjType( + entries: readonly TypedCatalogEntry[], +): Map { + const counts = new Map(); + for (const entry of entries) { + counts.set(entry.objType, (counts.get(entry.objType) ?? 0) + 1); + } + return counts; +} + +export function favoriteKey(kind: CatalogKind, id: number): string { + return `${kind}:${id}`; +} + +/** + * Toggle a favorite entry. Newest favorite is prepended; capped at `limit`. + */ +export function toggleFavoriteEntry( + current: readonly CatalogFavorite[], + entry: CatalogFavorite, + limit = 24, +): CatalogFavorite[] { + const exists = current.some( + (fav) => fav.kind === entry.kind && fav.id === entry.id, + ); + + if (exists) { + return current.filter( + (fav) => fav.kind !== entry.kind || fav.id !== entry.id, + ); + } + + const withoutDuplicate = current.filter( + (fav) => fav.kind !== entry.kind || fav.id !== entry.id, + ); + return [entry, ...withoutDuplicate].slice(0, limit); +} + +export function isFavorite( + favorites: readonly CatalogFavorite[], + kind: CatalogKind, + id: number, +): boolean { + return favorites.some((fav) => fav.kind === kind && fav.id === id); +} + +/** + * Parse favorites from raw localStorage JSON. Invalid shapes return []. + */ +export function parseFavorites(raw: unknown): CatalogFavorite[] { + if (!Array.isArray(raw)) { + return []; + } + + return raw.filter((entry): entry is CatalogFavorite => { + if (typeof entry !== "object" || entry === null) { + return false; + } + const candidate = entry as CatalogFavorite; + return ( + ["terrain", "object", "npc"].includes(candidate.kind) && + typeof candidate.id === "number" && + Number.isFinite(candidate.id) && + typeof candidate.grhIndex === "number" && + Number.isFinite(candidate.grhIndex) && + typeof candidate.name === "string" + ); + }); +} diff --git a/frontend/lib/editor/editorStore.tsx b/frontend/lib/editor/editorStore.tsx index 7a47f422..df7a81cb 100644 --- a/frontend/lib/editor/editorStore.tsx +++ b/frontend/lib/editor/editorStore.tsx @@ -26,6 +26,12 @@ import { listEditorNpcs, listEditorObjects, } from "./editorApi"; +import { + isFavorite as isFavoriteEntry, + parseFavorites, + toggleFavoriteEntry, + type CatalogFavorite, +} from "./catalogFilter"; import { UPLOADED_GRAPHIC_INDEX_START } from "../../utils/gameLoader"; /** @@ -116,6 +122,9 @@ export type RecentsEntry = { name: string; }; +/** Favorito del editor; misma forma que un reciente (#29 favoritos). */ +export type FavoritesEntry = CatalogFavorite; + type EditorStoreValue = { mapNum: number; setMapNum: (mapNum: number) => void; @@ -129,6 +138,9 @@ type EditorStoreValue = { setTool: (tool: EditorTool | null) => void; recents: RecentsEntry[]; addRecent: (entry: RecentsEntry) => void; + favorites: FavoritesEntry[]; + toggleFavorite: (entry: FavoritesEntry) => void; + isFavorite: (kind: FavoritesEntry["kind"], id: number) => boolean; refreshMapData: () => Promise; refreshStatus: () => Promise; isLoading: boolean; @@ -136,6 +148,7 @@ type EditorStoreValue = { }; const RECENTS_STORAGE_KEY = "editor.recents.v1"; +const FAVORITES_STORAGE_KEY = "editor.favorites.v1"; const DEFAULT_MAP_NUM = 1; const EditorStoreContext = createContext(null); @@ -181,6 +194,33 @@ function writeRecentsToStorage(recents: RecentsEntry[]): void { } } +function readFavoritesFromStorage(): FavoritesEntry[] { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(FAVORITES_STORAGE_KEY); + if (!raw) { + return []; + } + return parseFavorites(JSON.parse(raw) as unknown); + } catch { + return []; + } +} + +function writeFavoritesToStorage(favorites: FavoritesEntry[]): void { + try { + window.localStorage.setItem( + FAVORITES_STORAGE_KEY, + JSON.stringify(favorites), + ); + } catch { + // El almacenamiento puede estar lleno o bloqueado; se ignora. + } +} + export function EditorStoreProvider({ initialMapNum = DEFAULT_MAP_NUM, children, @@ -199,6 +239,9 @@ export function EditorStoreProvider({ const [recents, setRecents] = useState(() => readRecentsFromStorage(), ); + const [favorites, setFavorites] = useState(() => + readFavoritesFromStorage(), + ); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); const refreshTokenRef = useRef(0); @@ -270,6 +313,20 @@ export function EditorStoreProvider({ }); }, []); + const toggleFavorite = useCallback((entry: FavoritesEntry) => { + setFavorites((current) => { + const next = toggleFavoriteEntry(current, entry); + writeFavoritesToStorage(next); + return next; + }); + }, []); + + const isFavorite = useCallback( + (kind: FavoritesEntry["kind"], id: number) => + isFavoriteEntry(favorites, kind, id), + [favorites], + ); + useEffect(() => { let cancelled = false; @@ -315,6 +372,9 @@ export function EditorStoreProvider({ setTool, recents, addRecent, + favorites, + toggleFavorite, + isFavorite, refreshMapData, refreshStatus, isLoading, @@ -323,6 +383,8 @@ export function EditorStoreProvider({ [ addRecent, entities, + favorites, + isFavorite, isLoading, loadError, mapNum, @@ -335,6 +397,7 @@ export function EditorStoreProvider({ setMapNum, status, terrain, + toggleFavorite, tool, ], );