ps99-api 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.github/workflows/release-on-main.yml +1 -2
  2. package/.idea/runConfigurations/test_changing.xml +1 -1
  3. package/debug_currency.json +57 -0
  4. package/debug_goals.json +271 -0
  5. package/dist/ps99-api.d.ts +2 -0
  6. package/dist/ps99-api.js +4 -1
  7. package/dist/ps99-api.js.map +1 -1
  8. package/dist/request-client/axios.js +6 -1
  9. package/dist/request-client/axios.js.map +1 -1
  10. package/dist/responses/collection/index.d.ts +1 -0
  11. package/dist/responses/collection/index.js +15 -0
  12. package/dist/responses/collection/index.js.map +1 -1
  13. package/dist/responses/collection/rarity.d.ts +1 -0
  14. package/example-web/react/package-lock.json +1504 -1470
  15. package/example-web/react2/package-lock.json +3089 -2766
  16. package/example-web/react2/package.json +6 -1
  17. package/example-web/react2/public/assets/gold_variant_icon.png +0 -0
  18. package/example-web/react2/public/assets/hot_cocoa_egg.png +0 -0
  19. package/example-web/react2/public/index.html +34 -31
  20. package/example-web/react2/src/App.tsx +15 -15
  21. package/example-web/react2/src/assets/guild_placeholder.png +0 -0
  22. package/example-web/react2/src/components/AchievementsComponent.tsx +74 -19
  23. package/example-web/react2/src/components/AutoSizer.tsx +49 -0
  24. package/example-web/react2/src/components/BoostsComponent.tsx +16 -5
  25. package/example-web/react2/src/components/BoothsComponent.tsx +22 -52
  26. package/example-web/react2/src/components/BoxesComponent.tsx +48 -16
  27. package/example-web/react2/src/components/BuffsComponent.tsx +82 -34
  28. package/example-web/react2/src/components/CharmsComponent.tsx +84 -24
  29. package/example-web/react2/src/components/CollectionConfigIndex.tsx +867 -33
  30. package/example-web/react2/src/components/CollectionsIndex.tsx +380 -27
  31. package/example-web/react2/src/components/CollectionsLayout.tsx +60 -0
  32. package/example-web/react2/src/components/CurrencyComponent.tsx +57 -39
  33. package/example-web/react2/src/components/DynamicCollectionConfigData.tsx +172 -15
  34. package/example-web/react2/src/components/EggsComponent.tsx +50 -12
  35. package/example-web/react2/src/components/EnchantsComponent.tsx +88 -42
  36. package/example-web/react2/src/components/FishingRodsComponent.tsx +36 -22
  37. package/example-web/react2/src/components/Footer.tsx +18 -8
  38. package/example-web/react2/src/components/FruitsComponent.tsx +40 -17
  39. package/example-web/react2/src/components/GenericFetchComponent.tsx +9 -1
  40. package/example-web/react2/src/components/GuildBattlesComponent.tsx +41 -34
  41. package/example-web/react2/src/components/Header.tsx +39 -52
  42. package/example-web/react2/src/components/HomePage.tsx +15 -17
  43. package/example-web/react2/src/components/HoverboardsComponent.tsx +23 -99
  44. package/example-web/react2/src/components/ImageComponent.tsx +255 -45
  45. package/example-web/react2/src/components/ItemCard.tsx +240 -0
  46. package/example-web/react2/src/components/LootboxesComponent.tsx +22 -7
  47. package/example-web/react2/src/components/MasteryComponent.tsx +165 -30
  48. package/example-web/react2/src/components/MerchantsComponent.tsx +41 -16
  49. package/example-web/react2/src/components/MiscItemsComponent.tsx +26 -31
  50. package/example-web/react2/src/components/PetsComponent.tsx +100 -61
  51. package/example-web/react2/src/components/PotionsComponent.tsx +121 -27
  52. package/example-web/react2/src/components/RandomEventsComponent.tsx +32 -23
  53. package/example-web/react2/src/components/RanksComponent.tsx +187 -62
  54. package/example-web/react2/src/components/RarityComponent.tsx +123 -5
  55. package/example-web/react2/src/components/ReactWindowMock.tsx +73 -0
  56. package/example-web/react2/src/components/RebirthsComponent.tsx +36 -19
  57. package/example-web/react2/src/components/SecretRoomsComponent.tsx +5 -4
  58. package/example-web/react2/src/components/SeedsComponent.tsx +41 -21
  59. package/example-web/react2/src/components/ShovelsComponent.tsx +21 -9
  60. package/example-web/react2/src/components/Sidebar.tsx +105 -0
  61. package/example-web/react2/src/components/SprinklersComponent.tsx +25 -10
  62. package/example-web/react2/src/components/Tooltip.tsx +36 -0
  63. package/example-web/react2/src/components/UltimatesComponent.tsx +28 -16
  64. package/example-web/react2/src/components/UpgradesComponent.tsx +97 -47
  65. package/example-web/react2/src/components/WateringCansComponent.tsx +20 -14
  66. package/example-web/react2/src/components/WorldsComponent.tsx +21 -11
  67. package/example-web/react2/src/components/XPPotionsComponent.tsx +28 -11
  68. package/example-web/react2/src/components/ZoneFlagsComponent.tsx +25 -14
  69. package/example-web/react2/src/components/ZonesComponent.tsx +43 -60
  70. package/example-web/react2/src/constants/collectionIcons.ts +29 -0
  71. package/example-web/react2/src/context/CollectionDataContext.tsx +62 -0
  72. package/example-web/react2/src/context/ScrollContext.tsx +35 -0
  73. package/example-web/react2/src/hooks/useCollapsibleHeader.ts +69 -0
  74. package/example-web/react2/src/hooks/useExpandableList.ts +38 -0
  75. package/example-web/react2/src/hooks/useItemResolution.ts +351 -0
  76. package/example-web/react2/src/index.css +257 -0
  77. package/example-web/react2/src/index.tsx +2 -1
  78. package/example-web/react2/src/utils/gigantix.ts +40 -0
  79. package/example-web/react2/temp_model.rbxm +0 -0
  80. package/example-web/react2/webpack.config.js +103 -47
  81. package/package.json +11 -11
  82. package/ranks.json +1 -0
  83. package/repro_collection_fetch.ts +33 -0
  84. package/repro_image_fetch.ts +50 -0
  85. package/src/__tests__/__snapshots__/ps99-api-changes.ts.snap +34841 -10439
  86. package/src/__tests__/__snapshots__/ps99-api-live.ts.snap +160667 -67217
  87. package/src/ps99-api.ts +9 -5
  88. package/src/request-client/axios.ts +6 -2
  89. package/src/responses/collection/index.ts +1 -0
  90. package/src/responses/collection/rarity.ts +1 -0
  91. package/tsconfig.json +1 -1
  92. package/example-web/react2/public/service-worker.js +0 -63
@@ -1,50 +1,884 @@
1
- import React, { useEffect, useState } from "react";
2
- import { useParams, Link } from "react-router-dom";
3
- import { PetSimulator99API, CollectionName } from "ps99-api";
1
+ // @ts-nocheck
2
+ import React, { useEffect, useState, useRef } from "react";
3
+ import { useParams, Link, useNavigate } from "react-router-dom";
4
+ import Tooltip from "./Tooltip";
5
+ import { PetSimulator99API, Collections, CollectionName } from "ps99-api";
6
+ import ItemCard from "./ItemCard";
7
+ import DynamicCollectionConfigData from "./DynamicCollectionConfigData";
8
+ import ImageComponent from "./ImageComponent";
4
9
 
5
- const CollectionConfigIndex: React.FC = () => {
6
- const { collectionName } = useParams<{ collectionName: CollectionName }>();
7
- const [configNames, setConfigNames] = useState<string[]>([]);
8
- const [error, setError] = useState<string | null>(null);
10
+ import { useItemResolution } from "../hooks/useItemResolution";
11
+ import { useCollectionData } from "../context/CollectionDataContext";
12
+ import { FixedSizeGrid, FixedSizeList } from "./ReactWindowMock";
13
+ import AutoSizer from "./AutoSizer";
14
+ import { useScrollPersistence } from "../context/ScrollContext";
15
+ import { useCollapsibleHeader } from '../hooks/useCollapsibleHeader';
16
+ import { formatGigantix } from "../utils/gigantix";
9
17
 
18
+ // const FixedSizeGrid = Grid;
19
+ // const FixedSizeList = List;
20
+
21
+ // Collections that need prefix stripping
22
+ const collectionsToClean = new Set([
23
+ "Achievement", "Achievements",
24
+ "Boost", "Boosts",
25
+ "Booth", "Booths",
26
+ "Box", "Boxes",
27
+ "Charms",
28
+ "Currency",
29
+ "Enchants",
30
+ "FishingRods",
31
+ "Fruit", "Fruits",
32
+ "Hoverboards",
33
+ "Mastery", "Masteries",
34
+ "Potions",
35
+ "RandomEvents",
36
+ "Rebirths",
37
+ "SecretRooms",
38
+ "Seeds",
39
+ "Shovels",
40
+ "Sprinklers",
41
+ "Ultimates",
42
+ "Upgrades",
43
+ "WateringCans",
44
+ "ZoneFlags",
45
+ "XPPotions"
46
+ ]);
47
+
48
+ // Collections that should default to Compact List View
49
+ const COMPACT_COLLECTIONS = new Set([
50
+ "ZoneFlags",
51
+ "Enchants",
52
+ "Fruit",
53
+ "Boosts",
54
+ "Charms",
55
+ "Hoverboards",
56
+ "XPPotions",
57
+ "Seeds",
58
+ "Sprinklers",
59
+ "WateringCans",
60
+ "Shovels",
61
+ "FishingRods",
62
+ "Booths",
63
+ "Boxes",
64
+ "Rebirths",
65
+ "RandomEvents",
66
+ "SecretRooms",
67
+ "Ultimates"
68
+ ]);
69
+
70
+
71
+ function getCleanName(name: string, collectionName: string): string {
72
+ if (!name || !collectionsToClean.has(collectionName)) return name;
73
+
74
+ const prefixes = [collectionName];
75
+ if (collectionName.endsWith('s')) prefixes.push(collectionName.slice(0, -1));
76
+ if (collectionName === 'Boxes') prefixes.push('Box');
77
+
78
+ for (const prefix of prefixes) {
79
+ if (name.startsWith(prefix)) {
80
+ const remainder = name.slice(prefix.length);
81
+ if (remainder.length === 0) return ""; // Exact match returns empty to allow fallback
82
+ if (remainder.startsWith(" | ")) return remainder.slice(3);
83
+ if (remainder.startsWith(" - ")) return remainder.slice(3);
84
+ if (remainder.startsWith(" ")) return remainder.slice(1);
85
+ }
86
+ }
87
+ return name;
88
+ }
89
+
90
+ // --- Grid Cell Renderer ---
91
+ const GridCellRenderer = ({ columnIndex, rowIndex, style, data }: any) => {
92
+ const { items, columnCount, navigate, collectionName, variantFilter, shinyFilter, resolveIcon, GAP } = data;
93
+ const index = rowIndex * columnCount + columnIndex;
94
+ if (index >= items.length) return null;
95
+ const item = items[index];
96
+ const itemConfig = (item as any).configData || item;
97
+ const icon = resolveIcon(itemConfig);
98
+ const itemDataWithIcon = { ...itemConfig, icon };
99
+
100
+ const rawName = itemConfig.DisplayName || itemConfig.name || item.configName;
101
+ let label = getCleanName(rawName, collectionName);
102
+ if (!label && item.configName) {
103
+ label = getCleanName(item.configName, collectionName);
104
+ }
105
+ if (!label) label = rawName;
106
+
107
+ return (
108
+ <div style={style}>
109
+ <div style={{
110
+ position: 'absolute',
111
+ top: GAP / 2,
112
+ left: GAP / 2,
113
+ right: GAP / 2,
114
+ bottom: GAP / 2,
115
+ }}>
116
+ <div
117
+ onClick={() => navigate(`/collections/${collectionName}/${item.configName}`)}
118
+ style={{ cursor: "pointer", height: '100%' }}
119
+ >
120
+ <ItemCard
121
+ id={item.configName}
122
+ amount={""}
123
+ label={label}
124
+ itemData={itemDataWithIcon}
125
+ rarityColor={itemConfig.rarity?.Color || (itemConfig.Rarity?.Color)}
126
+ variant={collectionName === "Pets" ? (variantFilter as any) : undefined}
127
+ shiny={collectionName === "Pets" ? shinyFilter : undefined}
128
+ />
129
+ </div>
130
+ </div>
131
+ </div>
132
+ );
133
+ };
134
+
135
+ // --- List Row Renderer ---
136
+ const ListRowRenderer = ({ index, style, data }: any) => {
137
+ const { items, navigate, collectionName, resolveIcon, getRarityColor, variantFilter, shinyFilter } = data;
138
+ const item = items[index];
139
+ if (!item) return null;
140
+
141
+ const itemConfig = (item as any).configData || item;
142
+ if (collectionName === "Enchants" && index < 3) {
143
+ console.log(`[Enchants Debug] Item ${index}:`, itemConfig);
144
+ console.log(`[Enchants Debug] Diminish:`, itemConfig.DiminishPowerThreshold);
145
+ console.log(`[Enchants Debug] Tiers:`, itemConfig.Tiers);
146
+ }
147
+ const icon = resolveIcon(itemConfig);
148
+ const rarityColor = itemConfig.rarity?.Color || itemConfig.Rarity?.Color || getRarityColor(itemConfig.rarity || itemConfig.Rarity) || "#ccc";
149
+
150
+ const rawName = itemConfig.DisplayName || itemConfig.name || item.configName;
151
+ let name = getCleanName(rawName, collectionName);
152
+
153
+ // If cleaning resulted in empty string (e.g. "Hoverboard" -> ""), try cleaning the configName ("Hoverboard | Original" -> "Original")
154
+ if (!name && item.configName) {
155
+ name = getCleanName(item.configName, collectionName);
156
+ }
157
+ // If still empty, revert to rawName
158
+ if (!name) name = rawName;
159
+
160
+ const rawSubtext = itemConfig.Description || itemConfig.Desc || (itemConfig.Tiers && itemConfig.Tiers[0]?.Desc) || item.configName;
161
+ const cleanSubtext = (itemConfig.Description || itemConfig.Desc || (itemConfig.Tiers && itemConfig.Tiers[0]?.Desc)) ? (itemConfig.Description || itemConfig.Desc || (itemConfig.Tiers && itemConfig.Tiers[0]?.Desc)) : getCleanName(item.configName, collectionName);
162
+
163
+ // Visual Styles for Pets
164
+ let rowBorder = `2px solid #e0e0e0`;
165
+ let rowBg = "#f9f9f9";
166
+ let iconFilter = "none";
167
+ let rainbowBackground = "";
168
+
169
+ if (collectionName === "Pets") {
170
+ if (variantFilter === "Golden") {
171
+ iconFilter = "sepia(100%) saturate(300%) hue-rotate(10deg)";
172
+ rowBorder = "2px solid #FFD700";
173
+ rowBg = "#FFFDF0";
174
+ } else if (variantFilter === "Rainbow") {
175
+ iconFilter = "saturate(200%) hue-rotate(30deg) contrast(120%)";
176
+ // Gradient border trick -> requires setting background to padding-box + border-box
177
+ rainbowBackground = "linear-gradient(#f9f9f9, #f9f9f9) padding-box, linear-gradient(45deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #9400d3) border-box";
178
+ rowBorder = "2px solid transparent";
179
+ }
180
+ }
181
+
182
+ return (
183
+ <div style={{ ...style, padding: "5px 10px", boxSizing: "border-box" }}>
184
+ <div
185
+ onClick={() => navigate(`/collections/${collectionName}/${item.configName}`)}
186
+ style={{
187
+ display: "flex",
188
+ alignItems: "center",
189
+ backgroundColor: rowBg,
190
+ background: rainbowBackground || rowBg,
191
+ borderRadius: "12px",
192
+ padding: "8px 15px",
193
+ height: "calc(100% - 10px)", // Account for padding
194
+ cursor: "pointer",
195
+ border: rowBorder,
196
+ boxShadow: "0 2px 5px rgba(0,0,0,0.05)",
197
+ borderLeft: (collectionName !== "Pets" || variantFilter === "Normal") ? `6px solid ${rarityColor}` : undefined,
198
+ transition: "transform 0.1s active",
199
+ position: "relative",
200
+ overflow: "hidden"
201
+ }}
202
+ onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.98)"}
203
+ onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
204
+ onMouseLeave={(e) => e.currentTarget.style.transform = "scale(1)"}
205
+ >
206
+ {/* Shiny Overlay */}
207
+ {collectionName === "Pets" && shinyFilter && (
208
+ <div style={{
209
+ position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
210
+ pointerEvents: "none", zIndex: 10,
211
+ backgroundImage: "url('data:image/svg+xml;utf8,%3Csvg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cpath d=\"M10 0L12 8L20 10L12 12L10 20L8 12L0 10L8 8L10 0Z\" fill=\"%23FFD700\" opacity=\"0.4\"/%3E%3C/svg%3E')",
212
+ backgroundSize: "40px 40px", opacity: 0.5,
213
+ }} />
214
+ )}
215
+
216
+ {/* Icon (if available) */}
217
+ {icon ? (
218
+ <div style={{
219
+ width: "48px",
220
+ height: "48px",
221
+ marginRight: "15px",
222
+ flexShrink: 0,
223
+ borderRadius: "8px",
224
+ overflow: "hidden",
225
+ backgroundColor: "#fff",
226
+ border: "1px solid #eee",
227
+ display: "flex", alignItems: "center", justifyContent: "center",
228
+ zIndex: 2
229
+ }}>
230
+ <ImageComponent
231
+ src={icon}
232
+ alt={name}
233
+ style={{ width: "100%", height: "100%", objectFit: "contain", filter: iconFilter }}
234
+ />
235
+ </div>
236
+ ) : (
237
+ <div style={{
238
+ width: "48px", height: "48px", marginRight: "15px", flexShrink: 0,
239
+ display: "flex", alignItems: "center", justifyContent: "center",
240
+ fontSize: "1.5rem", backgroundColor: "#eee", borderRadius: "8px", color: "#aaa",
241
+ zIndex: 2
242
+ }}>
243
+ ?
244
+ </div>
245
+ )}
246
+
247
+ {/* Text Content */}
248
+ <div style={{ flex: 1, overflow: "hidden", zIndex: 2 }}>
249
+ <div style={{
250
+ fontSize: "1.1rem",
251
+ fontWeight: "700",
252
+ color: "#333",
253
+ whiteSpace: "nowrap",
254
+ overflow: "hidden",
255
+ textOverflow: "ellipsis",
256
+ fontFamily: "'Fredoka One', cursive, sans-serif",
257
+ }}>
258
+ {name}
259
+ </div>
260
+ {/* Optional Subtext - Only show if different from name */}
261
+ {(cleanSubtext && cleanSubtext !== name) && (
262
+ <div style={{
263
+ fontSize: "0.85rem",
264
+ color: "#666",
265
+ marginTop: "2px",
266
+ display: "-webkit-box",
267
+ WebkitLineClamp: 2,
268
+ WebkitBoxOrient: "vertical",
269
+ overflow: "hidden",
270
+ lineHeight: "1.2em",
271
+ maxHeight: "2.4em"
272
+ }}>
273
+ {cleanSubtext}
274
+ </div>
275
+ )}
276
+
277
+ {/* Generic Stats Rendering */}
278
+ <div style={{ display: 'flex', gap: '15px', marginTop: '2px', alignItems: 'center', flexWrap: 'wrap' }}>
279
+ {/* Duration / Time / ZoneFlags */}
280
+ {(itemConfig.Duration || itemConfig.Time) && (
281
+ <div style={{ fontSize: "0.85rem", color: "#1976d2", fontWeight: "600" }}>
282
+ ⏱ {formatGigantix(itemConfig.Duration || itemConfig.Time)}s
283
+ </div>
284
+ )}
285
+
286
+ {/* Power (Enchants/Potions) */}
287
+ {(itemConfig.Power || (itemConfig.Tiers && itemConfig.Tiers[0]?.Power)) && (
288
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
289
+ <div style={{ fontSize: "0.85rem", color: "#E65100", fontWeight: "600" }}>
290
+ ⚡ {formatGigantix(itemConfig.Power || itemConfig.Tiers[0]?.Power)}
291
+ </div>
292
+
293
+ {/* Enchant Diminish Cap Calculator */}
294
+ {collectionName === "Enchants" && itemConfig.DiminishPowerThreshold && itemConfig.Tiers && itemConfig.Tiers.length > 0 && (
295
+ (() => {
296
+ const maxTier = itemConfig.Tiers[itemConfig.Tiers.length - 1];
297
+ const power = maxTier.Power;
298
+ if (!power) return null;
299
+
300
+ const countToCap = Math.ceil(itemConfig.DiminishPowerThreshold / power);
301
+
302
+ return (
303
+ <div style={{
304
+ fontSize: "0.75rem",
305
+ backgroundColor: "#fff3e0",
306
+ color: "#e65100",
307
+ border: "1px solid #ffe0b2",
308
+ padding: "2px 6px",
309
+ borderRadius: "6px",
310
+ display: "flex",
311
+ alignItems: "center",
312
+ gap: "4px",
313
+ fontWeight: "bold"
314
+ }} title={`Diminish Threshold: ${itemConfig.DiminishPowerThreshold} | Max Tier Power: ${power}`}>
315
+ <span>🛑 Cap: {countToCap}x</span>
316
+ </div>
317
+ );
318
+ })()
319
+ )}
320
+ </div>
321
+ )}
322
+
323
+ {/* Speed (Hoverboards) */}
324
+ {(collectionName === "Hoverboards" || itemConfig.Speed || itemConfig.DefaultJumpSpeedBoost) && (
325
+ <div style={{ fontSize: "0.85rem", color: "#2E7D32", fontWeight: "600" }}>
326
+ 💨 {itemConfig.Speed ? formatGigantix(itemConfig.Speed) : (itemConfig.DefaultJumpSpeedBoost || 'Normal')}
327
+ </div>
328
+ )}
329
+
330
+ {/* Boost (Boosts) */}
331
+ {(collectionName === "Boosts" || itemConfig.Boost || itemConfig.MaximumPercent) && (
332
+ <div style={{ fontSize: "0.85rem", color: "#9C27B0", fontWeight: "600" }}>
333
+ 🚀 {formatGigantix(itemConfig.Boost || itemConfig.MaximumPercent || 0)}%
334
+ </div>
335
+ )}
336
+
337
+ {/* Multiplier */}
338
+ {itemConfig.Multiplier && (
339
+ <div style={{ fontSize: "0.85rem", color: "#C2185B", fontWeight: "600" }}>
340
+ ✖ {itemConfig.Multiplier}x
341
+ </div>
342
+ )}
343
+ </div>
344
+ </div>
345
+
346
+ {/* Chevron > */}
347
+ <div style={{ marginLeft: "10px", color: "rgba(0,0,0,0.2)", fontWeight: "bold", fontSize: "1.2rem", zIndex: 2 }}>
348
+
349
+ </div>
350
+ </div>
351
+ </div>
352
+ );
353
+ };
354
+
355
+
356
+ interface CollectionConfigIndexProps { }
357
+
358
+ const CollectionConfigIndex: React.FC<CollectionConfigIndexProps> = () => {
359
+ const { collectionName: rawCollectionName } = useParams<{ collectionName: string }>();
360
+ // Normalize collection name to Title Case (e.g. "pets" -> "Pets") to match logic checks
361
+ const collectionName = React.useMemo(() => {
362
+ if (!rawCollectionName) return "";
363
+ return rawCollectionName.charAt(0).toUpperCase() + rawCollectionName.slice(1);
364
+ }, [rawCollectionName]);
365
+
366
+ const navigate = useNavigate();
367
+ // Call the hook at the top level so we can use it
368
+ const { resolveIcon, getRarityColor } = useItemResolution();
369
+
370
+ const { data, fetchCollection, isLoading } = useCollectionData();
371
+
372
+ const items = (data[collectionName || ""] || []) as Collections[];
373
+ const loading = isLoading(collectionName || "");
374
+
375
+ const [searchTerm, setSearchTerm] = useState<string>("");
376
+ const [windowWidth, setWindowWidth] = useState(window.innerWidth);
377
+
378
+ // State for Pets Filter
379
+ const [specialFilter, setSpecialFilter] = useState<"H" | "T" | "G" | null>(null);
380
+ const [variantFilter, setVariantFilter] = useState<"Normal" | "Golden" | "Rainbow">("Normal");
381
+ const [shinyFilter, setShinyFilter] = useState<boolean>(false);
382
+ const [showFiltersMobile, setShowFiltersMobile] = useState(false); // Mobile Toggle
383
+
384
+ // View Mode: 'grid' or 'list'
385
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
386
+
387
+ // Responsive check
388
+ useEffect(() => {
389
+ const handleResize = () => {
390
+ setWindowWidth(window.innerWidth);
391
+ };
392
+ window.addEventListener("resize", handleResize);
393
+ return () => window.removeEventListener("resize", handleResize);
394
+ }, []);
395
+
396
+ const isMobile = windowWidth < 768;
397
+
398
+ // Scroll Persistence
399
+ const { saveScrollPosition, getScrollPosition } = useScrollPersistence();
400
+ const scrollKey = `collection_config_${collectionName || 'default'}_${viewMode}`;
401
+ const initialScrollOffset = getScrollPosition(scrollKey);
402
+
403
+ // Save on unmount
10
404
  useEffect(() => {
11
- const fetchConfigNames = async () => {
12
- if (!collectionName) return;
405
+ return () => {
406
+ saveScrollPosition(scrollKey, scrollRef.current);
407
+ };
408
+ }, [saveScrollPosition, scrollKey]);
13
409
 
14
- const api = new PetSimulator99API();
15
- const response = await api.getCollection(collectionName);
410
+ useEffect(() => {
411
+ if (collectionName) {
412
+ fetchCollection(collectionName as CollectionName);
413
+ }
414
+ }, [collectionName, fetchCollection]);
16
415
 
17
- if (response.status === "ok") {
18
- const names = response.data.map((item) => item.configName);
19
- setConfigNames(names);
416
+ // Process items for rendering
417
+ const processedItems = items.map((item) => {
418
+ const configData: any = (item as any).configData || item;
419
+ return {
420
+ ...item,
421
+ configData,
422
+ };
423
+ });
424
+
425
+ const filteredItems = processedItems.filter((item) => {
426
+ const config = item.configData;
427
+ const name =
428
+ config.DisplayName || config.name || item.configName || "";
429
+ if (
430
+ searchTerm &&
431
+ !name.toLowerCase().includes(searchTerm.toLowerCase())
432
+ ) {
433
+ return false;
434
+ }
435
+ return true;
436
+ });
437
+
438
+
439
+ // Apply Pet Filters
440
+ const finalItems = filteredItems.filter((item) => {
441
+ if (collectionName !== "Pets") return true;
442
+
443
+ const config = item.configData;
444
+ const name = config.DisplayName || config.name || item.configName || "";
445
+
446
+ // Special Filter
447
+ if (specialFilter === "H") {
448
+ if (!name.startsWith("Huge ")) return false;
449
+ } else if (specialFilter === "T") {
450
+ if (!name.startsWith("Titanic ")) return false;
451
+ } else if (specialFilter === "G") {
452
+ if (!name.startsWith("Gargantuan ")) return false;
453
+ } else {
454
+ // None selected: Exclude Huge, Titanic, Gargantuan
455
+ if (name.startsWith("Huge ") || name.startsWith("Titanic ") || name.startsWith("Gargantuan ")) return false;
456
+ }
457
+
458
+ return true;
459
+ });
460
+
461
+ // Determine View Mode
462
+ useEffect(() => {
463
+ if (isMobile) {
464
+ setViewMode('list');
465
+ return;
466
+ }
467
+
468
+ // Heuristic: If first 20 items have NO icons, default to list
469
+ // Only check if we have items
470
+ if (finalItems.length > 0) {
471
+ const sample = finalItems.slice(0, 20);
472
+ const hasImages = sample.some(item => !!resolveIcon(item.configData || item));
473
+ const hasImages = sample.some(item => !!resolveIcon(item.configData || item));
474
+ if (!hasImages || COMPACT_COLLECTIONS.has(collectionName)) {
475
+ setViewMode('list');
20
476
  } else {
21
- setError(response.error.message);
477
+ setViewMode('grid');
22
478
  }
23
- };
479
+ }
480
+ }, [isMobile, collectionName, finalItems.length]); // Re-evaluate on collection change or mobile resize, or items load
481
+
24
482
 
25
- fetchConfigNames();
26
- }, [collectionName]);
483
+ // Scroll Direction // Header Logic
484
+ const { showHeader, handleScroll, scrollRef, headerRef, headerHeight, contentPadding } = useCollapsibleHeader({ deps: [loading] });
27
485
 
28
- if (error) {
29
- return <div>Error: {error}</div>;
486
+ // Loading State
487
+ if (loading) {
488
+ return (
489
+ <div
490
+ style={{
491
+ display: "flex",
492
+ justifyContent: "center",
493
+ alignItems: "center",
494
+ height: "50vh",
495
+ fontSize: "1.5rem",
496
+ fontWeight: "bold",
497
+ color: "#666",
498
+ }}
499
+ >
500
+ Loading {collectionName}...
501
+ </div>
502
+ );
30
503
  }
31
504
 
32
- return (
33
- <div>
34
- <h2>{collectionName} Configurations</h2>
35
- <ul>
36
- {configNames.map((configName, index) => (
37
- <li key={index}>
38
- <Link
39
- to={`/collections/${collectionName}/${encodeURIComponent(configName)}`}
40
- >
41
- {configName}
42
- </Link>
43
- </li>
505
+ const renderFilters = () => (
506
+ <div style={{
507
+ display: "flex",
508
+ gap: "8px",
509
+ alignItems: "center",
510
+ flexWrap: isMobile ? "wrap" : "nowrap",
511
+ justifyContent: isMobile ? "center" : "flex-start",
512
+ padding: isMobile ? "10px 0" : "0",
513
+ backgroundColor: isMobile ? "#f5f5f5" : "transparent",
514
+ width: isMobile ? "100%" : "auto",
515
+ borderRadius: isMobile ? "12px" : "0",
516
+ }}>
517
+ {/* Specials: H, T, G */}
518
+ <div style={{ display: 'flex', gap: '5px' }}>
519
+ {['H', 'T', 'G'].map((symbol) => (
520
+ <button
521
+ key={symbol}
522
+ onClick={() => setSpecialFilter(specialFilter === symbol ? null : symbol as any)}
523
+ style={{
524
+ width: "36px",
525
+ height: "36px",
526
+ borderRadius: "50%",
527
+ border: "3px solid #333",
528
+ backgroundColor: specialFilter === symbol ? "#333" : "#fff",
529
+ color: specialFilter === symbol ? "#fff" : "#333",
530
+ fontSize: "1rem",
531
+ fontWeight: "bold",
532
+ cursor: "pointer",
533
+ display: "flex",
534
+ alignItems: "center",
535
+ justifyContent: "center",
536
+ boxShadow: "0 2px 0 #ccc",
537
+ }}
538
+ >
539
+ {symbol}
540
+ </button>
44
541
  ))}
45
- </ul>
542
+ </div>
543
+
544
+ <div style={{ width: "1px", height: "30px", backgroundColor: "#ddd", margin: "0 5px" }}></div>
545
+
546
+ {/* Variants: Normal, Golden, Rainbow */}
547
+ <div style={{ display: 'flex', gap: '5px' }}>
548
+ {[
549
+ { symbol: '🐾', mode: 'Normal', color: '#fff' },
550
+ { symbol: '★', mode: 'Golden', color: '#FFD700' },
551
+ { symbol: '🌈', mode: 'Rainbow', color: 'inherit' }
552
+ ].map(({ symbol, mode, color: iconColor }) => {
553
+ const isActive = variantFilter === mode;
554
+ const isRainbow = mode === 'Rainbow';
555
+ const isGold = mode === 'Golden';
556
+
557
+ return (
558
+ <button
559
+ key={mode}
560
+ onClick={() => setVariantFilter(mode as any)}
561
+ className={(isActive && isRainbow) ? "sheen-effect" : ""}
562
+ style={{
563
+ width: "40px", // slightly smaller for mobile fit
564
+ height: "40px",
565
+ borderRadius: "50%",
566
+ border: "3px solid #333",
567
+ backgroundColor: isActive
568
+ ? (mode === 'Normal' ? "#e0e0e0" : "#333")
569
+ : "#fff",
570
+ color: isActive
571
+ ? (mode === 'Normal' ? "#333" : "#fff")
572
+ : "#333",
573
+ fontSize: "1.2rem",
574
+ fontWeight: "bold",
575
+ cursor: "pointer",
576
+ display: "flex",
577
+ alignItems: "center",
578
+ justifyContent: "center",
579
+ boxShadow: "0 2px 0 #ccc",
580
+ filter: "none",
581
+ position: 'relative',
582
+ overflow: 'hidden',
583
+ padding: 0
584
+ }}
585
+ title={mode}
586
+ >
587
+ {isGold ? (
588
+ <img
589
+ src="/node-ps99-api/assets/gold_variant_icon.png"
590
+ alt="Gold"
591
+ style={{
592
+ width: '70%',
593
+ height: '70%',
594
+ objectFit: 'contain',
595
+ filter: isActive ? 'none' : 'grayscale(1) opacity(0.5)'
596
+ }}
597
+ />
598
+ ) : (
599
+ <span style={{
600
+ filter: mode === 'Golden' && !isActive ? "grayscale(1)" : "none",
601
+ color: (isActive && mode !== 'Rainbow' && mode !== 'Normal') ? iconColor : 'inherit'
602
+ }}>
603
+ {symbol}
604
+ </span>
605
+ )}
606
+ </button>
607
+ )
608
+ })}
609
+ </div>
610
+
611
+ {/* Shiny Toggle */}
612
+ <button
613
+ onClick={() => setShinyFilter(!shinyFilter)}
614
+ className={shinyFilter ? "sheen-effect" : ""}
615
+ style={{
616
+ width: "40px",
617
+ height: "40px",
618
+ borderRadius: "50%",
619
+ border: "3px solid #333",
620
+ backgroundColor: shinyFilter ? "#333" : "#fff",
621
+ color: shinyFilter ? "#fff" : "#333",
622
+ fontSize: "1.2rem",
623
+ fontWeight: "bold",
624
+ cursor: "pointer",
625
+ display: "flex",
626
+ alignItems: "center",
627
+ justifyContent: "center",
628
+ boxShadow: "0 2px 0 #ccc",
629
+ position: 'relative',
630
+ overflow: 'hidden',
631
+ marginLeft: "5px"
632
+ }}
633
+ >
634
+
635
+ </button>
46
636
  </div>
47
637
  );
638
+
639
+ return (
640
+ <div
641
+ style={{
642
+ flex: 1,
643
+ display: "flex",
644
+ flexDirection: "column",
645
+ overflow: "hidden",
646
+ backgroundColor: "#ffffff",
647
+ height: "100%", // ensure it fills parent
648
+ position: 'relative' // Context for absolute header
649
+ }}
650
+ >
651
+ {/* Header Bar inside Window */}
652
+ <div
653
+ ref={headerRef}
654
+ style={{
655
+ padding: isMobile ? "10px 15px" : "15px 20px",
656
+ borderBottom: "4px solid #333",
657
+ display: "flex",
658
+ flexDirection: "column",
659
+ gap: isMobile ? "10px" : "0px",
660
+ backgroundColor: "#fff",
661
+ position: "absolute",
662
+ top: 0,
663
+ left: 0,
664
+ right: 0,
665
+ zIndex: 10,
666
+ transition: "transform 0.3s ease-in-out",
667
+ transform: showHeader ? "translateY(0)" : "translateY(-100%)",
668
+ }}
669
+ >
670
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
671
+ <h2
672
+ style={{
673
+ margin: 0,
674
+ fontSize: isMobile ? "1.8rem" : "2.5rem",
675
+ fontWeight: "900",
676
+ color: "#333",
677
+ textShadow: isMobile ? "2px 2px 0px #eee" : "3px 3px 0px #eee",
678
+ fontFamily: "'Fredoka One', cursive, sans-serif",
679
+ letterSpacing: "1px",
680
+ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
681
+ maxWidth: isMobile ? "60%" : "auto"
682
+ }}
683
+ >
684
+ {collectionName}!
685
+ </h2>
686
+
687
+ {/* Mobile Filter Toggle (Pets Only) */}
688
+ {collectionName === "Pets" && isMobile && (
689
+ <button
690
+ onClick={() => setShowFiltersMobile(!showFiltersMobile)}
691
+ style={{
692
+ borderRadius: "12px",
693
+ border: "2px solid #333", background: showFiltersMobile ? "#333" : "#fff",
694
+ color: showFiltersMobile ? "#fff" : "#333",
695
+ fontSize: "1rem", display: "flex", alignItems: "center", justifyContent: "center",
696
+ marginRight: "10px",
697
+ padding: "8px 12px",
698
+ fontWeight: "bold",
699
+ boxShadow: "0 2px 0 #ccc",
700
+ }}
701
+ >
702
+ {showFiltersMobile ? "Hide Filters" : "Filters"}
703
+ </button>
704
+ )}
705
+
706
+ {/* Desktop Filter Ribbon (Pets Only) & Search */}
707
+ {!isMobile && (
708
+ <div style={{ display: 'flex', alignItems: 'center', flex: 1, justifyContent: 'flex-end', marginRight: '20px' }}>
709
+ {collectionName === "Pets" && renderFilters()}
710
+ <div style={{ position: "relative", marginLeft: collectionName === "Pets" ? "20px" : "0" }}>
711
+ <input
712
+ type="text"
713
+ placeholder="Search"
714
+ value={searchTerm}
715
+ onChange={(e) => setSearchTerm(e.target.value)}
716
+ style={{
717
+ padding: "10px 20px",
718
+ borderRadius: "50px",
719
+ border: "3px solid #ccc",
720
+ outline: "none",
721
+ fontSize: "1.2rem",
722
+ width: "200px",
723
+ fontWeight: "800",
724
+ backgroundColor: "#f9f9f9",
725
+ color: "#333",
726
+ }}
727
+ />
728
+ </div>
729
+ </div>
730
+ )}
731
+
732
+ {/* Red Close Button */}
733
+ <button
734
+ onClick={() => navigate("/collections")}
735
+ style={{
736
+ width: isMobile ? "40px" : "48px",
737
+ height: isMobile ? "40px" : "48px",
738
+ borderRadius: "12px",
739
+ backgroundColor: "#ff0055",
740
+ color: "white",
741
+ border: isMobile ? "3px solid #900" : "4px solid #900",
742
+ fontSize: "20px",
743
+ fontWeight: "900",
744
+ display: "flex",
745
+ alignItems: "center",
746
+ justifyContent: "center",
747
+ cursor: "pointer",
748
+ boxShadow: "inset 0 4px 4px rgba(255,255,255,0.4), 0 4px 0 #500",
749
+ flexShrink: 0
750
+ }}
751
+ >
752
+ X
753
+ </button>
754
+ </div>
755
+
756
+ {/* Mobile Search Row */}
757
+ {isMobile && (
758
+ <div style={{ width: "100%" }}>
759
+ <input
760
+ type="text"
761
+ placeholder={`Search ${collectionName}...`}
762
+ value={searchTerm}
763
+ onChange={(e) => setSearchTerm(e.target.value)}
764
+ style={{
765
+ padding: "10px 15px",
766
+ borderRadius: "12px",
767
+ border: "3px solid #ccc",
768
+ outline: "none",
769
+ fontSize: "1rem",
770
+ width: "100%",
771
+ fontWeight: "700",
772
+ backgroundColor: "#f5f5f5",
773
+ color: "#333",
774
+ boxSizing: "border-box"
775
+ }}
776
+ />
777
+ </div>
778
+ )}
779
+
780
+
781
+ {/* Mobile Filters Dropdown/Area */}
782
+ {collectionName === "Pets" && isMobile && showFiltersMobile && (
783
+ <div style={{ paddingTop: "10px", borderTop: "1px dashed #eee" }}>
784
+ {renderFilters()}
785
+ </div>
786
+ )}
787
+
788
+ </div>
789
+
790
+ {/* Virtualized and AutoSized Content */}
791
+ <div style={{ height: `calc(100% - ${contentPadding})`, width: "100%", flex: 1, marginTop: contentPadding, transition: "margin-top 0.3s ease-in-out, height 0.3s ease-in-out" }}>
792
+ {/* @ts-ignore */}
793
+ <AutoSizer style={{ width: "100%", height: "100%" }} renderProp={({ height, width }: { height: number; width: number }) => {
794
+ const GAP = 10;
795
+ // Adjustment for mobile header spacer
796
+ // If mobile, we are absolute positioning the header. The list needs to have padding TOP to account for it, OR we just let the list flow and use `paddingTop` on the container.
797
+ // However, AutoSizer gives us 'height' of the parent. We should subtract the header height IF it were static, but it's absolute.
798
+ // So we should just use the full height, but ensure the first items aren't hidden behind the header.
799
+ // A cleaner way for "Scroll to Hide" is to have the list occupy 100% height, and simply add a "Header Spacer" as the very first item in the list?
800
+ // OR, simpler: Use `paddingTop` on the container div above, which we are animating.
801
+ // Let's use `paddingTop` on the wrapper div. But wait, AutoSizer measures the `flex: 1` div.
802
+ // If we animate `paddingTop`, the `height` passed to `AutoSizer` will change (because flex child shrinks).
803
+ // That causes Re-renders of the list. That might be jerky.
804
+ // BETTER APPROACH: Keep the list static full screen, and rely on `contentContainerStyle` padding?
805
+ // React-window doesn't support dynamic contentContainerStyle easily without remounting.
806
+ // Let's try the simpler approach first: `paddingTop` on the container. resizing might be performant enough.
807
+ // actually, wait. If we use `paddingTop` on the flex container, the available height for AutoSizer shrinks.
808
+ // This is GOOD. The list gets smaller, but stays at the bottom.
809
+ // When header hides, padding becomes 0, list gets taller.
810
+ // The issue is: scrolling DOWN triggers hide -> list grows -> potential scroll jump?
811
+ // Let's test it.
812
+
813
+ return (
814
+ <>
815
+ {viewMode === 'list' ? (
816
+ /* @ts-ignore */
817
+ <FixedSizeList
818
+ height={height}
819
+ itemCount={finalItems.length}
820
+ itemSize={80} // List view row height
821
+ width={width}
822
+ initialScrollOffset={initialScrollOffset}
823
+ onScroll={handleScroll}
824
+ itemData={{
825
+ items: finalItems,
826
+ navigate,
827
+ collectionName,
828
+ resolveIcon,
829
+ getRarityColor,
830
+ variantFilter,
831
+ shinyFilter
832
+ }}
833
+ >
834
+ {/* @ts-ignore */}
835
+ {ListRowRenderer}
836
+ </FixedSizeList>
837
+ ) : (
838
+ /* @ts-ignore */
839
+ /* @ts-ignore */
840
+ (() => {
841
+ const SCROLLBAR_WIDTH = 40;
842
+ const effectiveWidth = width - SCROLLBAR_WIDTH;
843
+ const colCount = Math.floor(effectiveWidth / 150) || 1;
844
+ const colWidth = effectiveWidth / colCount;
845
+
846
+ return (
847
+ <FixedSizeGrid
848
+ columnCount={colCount}
849
+ columnWidth={colWidth}
850
+ height={height}
851
+ rowCount={Math.ceil(finalItems.length / colCount)}
852
+ rowHeight={220}
853
+ width={width}
854
+ initialScrollOffset={initialScrollOffset}
855
+ onScroll={handleScroll}
856
+ style={{ overflowX: "hidden" }}
857
+ itemData={{
858
+ items: finalItems,
859
+ columnCount: colCount,
860
+ navigate,
861
+ collectionName,
862
+ variantFilter,
863
+ shinyFilter,
864
+ resolveIcon,
865
+ GAP
866
+ }}
867
+ >
868
+ {/* @ts-ignore */}
869
+ {GridCellRenderer}
870
+ </FixedSizeGrid>
871
+ );
872
+ })()
873
+
874
+ )}
875
+ </>
876
+ );
877
+ }} />
878
+ </div>
879
+
880
+ </div >
881
+ );
48
882
  };
49
883
 
50
884
  export default CollectionConfigIndex;