Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/licensing-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions frontend/app/construccion/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -99,6 +100,7 @@ function ConstruccionEditor() {
</main>
</div>

<FavoritesStrip />
<RecentsStrip />
</div>
);
Expand Down
107 changes: 107 additions & 0 deletions frontend/components/editor/FavoritesStrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import { findTerrainBrush, useEditorStore } from "../../lib/editor/editorStore";
import GraphicPreview from "./GraphicPreview";

const KIND_LABELS: Record<string, string> = {
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 (
<div className="rounded-2xl border border-dashed border-white/10 px-4 py-2.5 text-[11px] text-stone-500">
Marca con ★ los objetos, NPCs o tiles que uses seguido; quedan
aca como favoritos entre sesiones.
</div>
);
}

return (
<div className="flex items-center gap-1.5 overflow-x-auto rounded-2xl border border-amber-400/20 bg-amber-400/5 px-3 py-2 backdrop-blur-md">
<span className="shrink-0 text-[10px] uppercase tracking-[0.2em] text-amber-400/80">
Favoritos
</span>
{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 (
<div
key={`${entry.kind}:${entry.id}`}
className="flex shrink-0 items-center gap-0.5 rounded-lg border border-amber-400/30 bg-stone-950/60"
>
<button
type="button"
disabled={!isAvailable}
onClick={() => {
if (matchedBrush) {
setTool({ kind: "terrain", ...matchedBrush });
} else if (matchedObject) {
setTool({
kind: "object",
object: matchedObject,
});
} else if (matchedNpc) {
setTool({ kind: "npc", npc: matchedNpc });
}
}}
title={
isAvailable
? `${KIND_LABELS[entry.kind] ?? entry.kind} ${entry.name} (#${entry.id})`
: `${entry.name} no esta disponible en este mapa`
}
className="flex items-center gap-1.5 px-2 py-1 text-[10px] text-stone-200 transition hover:text-amber-200 disabled:opacity-40"
>
<GraphicPreview
grhIndex={entry.grhIndex}
size={28}
scale={1}
/>
<span className="max-w-24 truncate">{entry.name}</span>
</button>
<button
type="button"
onClick={() => toggleFavorite(entry)}
title="Quitar de favoritos"
className="px-1.5 py-1 text-xs text-amber-300/80 transition hover:text-amber-200"
>
</button>
</div>
);
})}
</div>
);
}
119 changes: 80 additions & 39 deletions frontend/components/editor/NpcsBrowser.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<BodiesDB | null>(null);
const [headsDB, setHeadsDB] = useState<HeadsDB | null>(null);
const normalizedSearch = search.trim().toLowerCase();

useEffect(() => {
let cancelled = false;

Expand All @@ -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;

Expand Down Expand Up @@ -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 (
<div className="flex h-full min-h-0 flex-col gap-2">
<input
Expand All @@ -116,37 +129,65 @@ export default function NpcsBrowser() {
getItemKey={(entry) => 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 (
<button
type="button"
onClick={() => void handleSelect(entry)}
className={`flex h-[72px] w-full items-center gap-2 rounded-lg border px-2 text-left transition ${
<div
className={`group relative flex h-[72px] w-full items-center gap-2 rounded-lg border px-2 transition ${
isSelected
? "border-amber-400/70 bg-amber-400/15"
: "border-transparent bg-stone-950/40 hover:border-white/10 hover:bg-stone-900/70"
}`}
>
<GraphicPreview
grhIndex={resolveCharacterThumbnailGrh(
bodiesDB,
headsDB,
entry.idBody,
entry.idHead,
)}
size={56}
scale={1.8}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-stone-200">
{entry.name}
</p>
<p className="text-[10px] text-stone-500">
#{entry.id} - Cuerpo {entry.idBody} / Cabeza{" "}
{entry.idHead}
</p>
</div>
</button>
<button
type="button"
onClick={() => void handleSelect(entry)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<GraphicPreview
grhIndex={previewGrh}
size={56}
scale={1.8}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-stone-200">
{entry.name}
</p>
<p className="text-[10px] text-stone-500">
#{entry.id} - Cuerpo {entry.idBody} / Cabeza{" "}
{entry.idHead}
</p>
</div>
</button>
<button
type="button"
onClick={(event) =>
handleToggleFavorite(
event,
entry,
previewGrh,
)
}
title={
favorited
? "Quitar de favoritos"
: "Agregar a favoritos"
}
className={`shrink-0 px-1 text-sm transition ${
favorited
? "text-amber-300"
: "text-stone-600 opacity-0 group-hover:opacity-100 hover:text-amber-200"
}`}
>
</button>
</div>
);
}}
itemHeight={ITEM_HEIGHT}
Expand Down
Loading