feat(studio): add visual layout previews, automated unit tests and polish organize imports
This commit is contained in:
1 parent
eb3dc4e88c
commit
e47bee16bb
2 files changed
+132
-1
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Eye,
|
||||||
Folder,
|
Folder,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Home,
|
Home,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
CATALOG_LAYOUTS,
|
CATALOG_LAYOUTS,
|
||||||
type CatalogLayout,
|
type CatalogLayout,
|
||||||
isValidCatalogLayout,
|
isValidCatalogLayout,
|
||||||
|
LAYOUT_DESCRIPTIONS,
|
||||||
} from "@/lib/catalog-layouts";
|
} from "@/lib/catalog-layouts";
|
||||||
// import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
|
// import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
|
||||||
import { getLocalIconUrl } from "@/lib/furni/classname";
|
import { getLocalIconUrl } from "@/lib/furni/classname";
|
||||||
@@ -112,12 +114,18 @@ export function OrganizeImportsDialog({
|
|||||||
const [overrides, setOverrides] = useState<
|
const [overrides, setOverrides] = useState<
|
||||||
Record<string, OrganizeGroupOverride>
|
Record<string, OrganizeGroupOverride>
|
||||||
>({});
|
>({});
|
||||||
|
const [previewGroup, setPreviewGroup] = useState<OrganizeImportGroup | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
const [freeMode, setFreeMode] = useState(true);
|
const [freeMode, setFreeMode] = useState(true);
|
||||||
const [credits, setCredits] = useState(0);
|
const [credits, setCredits] = useState(0);
|
||||||
const [points, setPoints] = useState(0);
|
const [points, setPoints] = useState(0);
|
||||||
const [pointsType, setPointsType] = useState(0);
|
const [pointsType, setPointsType] = useState(0);
|
||||||
|
const [groupSearchQuery, setGroupSearchQuery] = useState("");
|
||||||
|
const [batchLayout, setBatchLayout] = useState<CatalogLayout>("default_3x3");
|
||||||
|
const [batchPrefix, setBatchPrefix] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -238,6 +246,48 @@ export function OrganizeImportsDialog({
|
|||||||
setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } }));
|
setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyBatchLayout = (layout: CatalogLayout) => {
|
||||||
|
setOverrides((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const g of groups) {
|
||||||
|
if (approved[g.name] !== false) {
|
||||||
|
next[g.name] = { ...next[g.name], layout };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyBatchPrefix = () => {
|
||||||
|
if (!batchPrefix.trim()) return;
|
||||||
|
setOverrides((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const g of groups) {
|
||||||
|
if (approved[g.name] !== false) {
|
||||||
|
const currentCaption = next[g.name]?.caption ?? g.name;
|
||||||
|
if (!currentCaption.startsWith(batchPrefix)) {
|
||||||
|
next[g.name] = {
|
||||||
|
...next[g.name],
|
||||||
|
caption: `${batchPrefix}${currentCaption}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredGroups = useMemo(() => {
|
||||||
|
const q = groupSearchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return groups;
|
||||||
|
return groups.filter((g) => {
|
||||||
|
if (g.name.toLowerCase().includes(q)) return true;
|
||||||
|
const caption = overrides[g.name]?.caption?.toLowerCase();
|
||||||
|
if (caption?.includes(q)) return true;
|
||||||
|
return g.thumbs.some((t) => t.itemName.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
}, [groups, groupSearchQuery, overrides]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (moveMode && destinationPageId == null) {
|
if (moveMode && destinationPageId == null) {
|
||||||
@@ -297,6 +347,7 @@ export function OrganizeImportsDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[800px] max-w-[95vw] flex-col overflow-hidden p-0">
|
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[800px] max-w-[95vw] flex-col overflow-hidden p-0">
|
||||||
<DialogHeader className="flex-none border-b px-4 py-3">
|
<DialogHeader className="flex-none border-b px-4 py-3">
|
||||||
@@ -625,7 +676,7 @@ export function OrganizeImportsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map((group) => {
|
{filteredGroups.map((group) => {
|
||||||
const o = overrides[group.name] ?? {};
|
const o = overrides[group.name] ?? {};
|
||||||
const caption = o.caption ?? group.name;
|
const caption = o.caption ?? group.name;
|
||||||
const icon = o.icon ?? group.icon;
|
const icon = o.icon ?? group.icon;
|
||||||
@@ -658,6 +709,20 @@ export function OrganizeImportsDialog({
|
|||||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 gap-1 px-1.5 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewGroup(group);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
{t("visualPreview")}
|
||||||
|
</Button>
|
||||||
<Badge variant="secondary" className="h-4 text-[9px]">
|
<Badge variant="secondary" className="h-4 text-[9px]">
|
||||||
{group.total}
|
{group.total}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -891,6 +956,58 @@ export function OrganizeImportsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Visual Preview Sub-Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={previewGroup != null}
|
||||||
|
onOpenChange={(open) => !open && setPreviewGroup(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CatalogIcon iconImage={previewGroup?.icon ?? 1} size={24} />
|
||||||
|
<span>{previewGroup?.name}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{previewGroup && (
|
||||||
|
<div className="flex flex-col gap-3 py-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between rounded border bg-muted/30 p-2.5">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{t("layout")}:</span>{" "}
|
||||||
|
<span className="font-mono text-[11px]">
|
||||||
|
{previewGroup.layout ?? "default_3x3"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{previewGroup.total} items
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{previewGroup.layout &&
|
||||||
|
LAYOUT_DESCRIPTIONS[previewGroup.layout] && (
|
||||||
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
|
{LAYOUT_DESCRIPTIONS[previewGroup.layout]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-[220px] overflow-y-auto rounded border p-2">
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{previewGroup.thumbs.map((thumb) => (
|
||||||
|
<div
|
||||||
|
key={thumb.itemName}
|
||||||
|
className="flex flex-col items-center gap-1 rounded border bg-background p-1.5"
|
||||||
|
>
|
||||||
|
<FurniThumb item={thumb} size={32} />
|
||||||
|
<span className="w-full truncate text-center text-[9px] text-muted-foreground">
|
||||||
|
{thumb.itemName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,4 +289,18 @@ describe("groupSuggestedCategories", () => {
|
|||||||
])[0];
|
])[0];
|
||||||
expect(group?.name).toBe("Trophies");
|
expect(group?.name).toBe("Trophies");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects special keyword groups like NFTs, Dragons, and Lasers", () => {
|
||||||
|
const groups = groupSuggestedCategories([
|
||||||
|
entry(1, "nft_trophy_gold"),
|
||||||
|
entry(2, "nft_avatar_statue"),
|
||||||
|
entry(3, "dragon_fire_lamp"),
|
||||||
|
entry(4, "laser_gate_cyan"),
|
||||||
|
]);
|
||||||
|
expect(groups.map((g) => g.name)).toContain("NFTs");
|
||||||
|
expect(groups.map((g) => g.name)).toContain("Dragons");
|
||||||
|
expect(groups.map((g) => g.name)).toContain("Lasers");
|
||||||
|
const nftGroup = groups.find((g) => g.name === "NFTs");
|
||||||
|
expect(nftGroup?.items.map((i) => i.id)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user