@rpg-engine/long-bow 0.7.90 → 0.7.92

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 (35) hide show
  1. package/dist/components/CraftBook/CraftingTooltip.d.ts +13 -0
  2. package/dist/components/CraftBook/components/CraftBookHeader.d.ts +9 -0
  3. package/dist/components/CraftBook/components/CraftBookPagination.d.ts +0 -0
  4. package/dist/components/CraftBook/components/CraftBookSearch.d.ts +0 -0
  5. package/dist/components/CraftBook/hooks/useCraftBookFilters.d.ts +9 -0
  6. package/dist/components/CraftBook/hooks/useFilteredItems.d.ts +9 -0
  7. package/dist/components/CraftBook/hooks/usePagination.d.ts +13 -0
  8. package/dist/components/CraftBook/hooks/useResponsiveSize.d.ts +6 -0
  9. package/dist/components/CraftBook/utils/modifyString.d.ts +1 -0
  10. package/dist/components/shared/Pagination/Pagination.d.ts +9 -0
  11. package/dist/components/shared/SearchBar/SearchBar.d.ts +10 -0
  12. package/dist/hooks/useLocalStorage.d.ts +1 -0
  13. package/dist/long-bow.cjs.development.js +464 -289
  14. package/dist/long-bow.cjs.development.js.map +1 -1
  15. package/dist/long-bow.cjs.production.min.js +1 -1
  16. package/dist/long-bow.cjs.production.min.js.map +1 -1
  17. package/dist/long-bow.esm.js +464 -290
  18. package/dist/long-bow.esm.js.map +1 -1
  19. package/dist/stories/Features/craftbook/CraftBook.stories.d.ts +2 -0
  20. package/package.json +1 -1
  21. package/src/components/CraftBook/CraftBook.tsx +306 -121
  22. package/src/components/CraftBook/CraftingRecipe.tsx +97 -97
  23. package/src/components/CraftBook/CraftingTooltip.tsx +137 -0
  24. package/src/components/CraftBook/components/CraftBookHeader.tsx +81 -0
  25. package/src/components/CraftBook/components/CraftBookPagination.tsx +1 -0
  26. package/src/components/CraftBook/components/CraftBookSearch.tsx +1 -0
  27. package/src/components/CraftBook/hooks/useCraftBookFilters.ts +39 -0
  28. package/src/components/CraftBook/hooks/useFilteredItems.ts +39 -0
  29. package/src/components/CraftBook/hooks/usePagination.ts +39 -0
  30. package/src/components/CraftBook/hooks/useResponsiveSize.ts +50 -0
  31. package/src/components/CraftBook/utils/modifyString.ts +11 -0
  32. package/src/components/shared/Pagination/Pagination.tsx +69 -0
  33. package/src/components/shared/SearchBar/SearchBar.tsx +52 -0
  34. package/src/hooks/useLocalStorage.ts +44 -0
  35. package/src/stories/Features/craftbook/CraftBook.stories.tsx +41 -1
@@ -2,3 +2,5 @@ import { Meta } from '@storybook/react';
2
2
  declare const meta: Meta;
3
3
  export default meta;
4
4
  export declare const Default: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
5
+ export declare const WithSearch: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
6
+ export declare const WithCategory: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpg-engine/long-bow",
3
- "version": "0.7.90",
3
+ "version": "0.7.92",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -6,11 +6,18 @@ import {
6
6
  ItemSubType,
7
7
  } from '@rpg-engine/shared';
8
8
  import React, { useEffect, useState } from 'react';
9
+ import {
10
+ FaChevronLeft,
11
+ FaChevronRight,
12
+ FaSearch,
13
+ FaThumbtack,
14
+ } from 'react-icons/fa';
9
15
  import styled from 'styled-components';
10
16
  import { uiColors } from '../../constants/uiColors';
17
+ import { useLocalStorage } from '../../hooks/useLocalStorage';
11
18
  import { Button, ButtonTypes } from '../Button';
12
19
  import { DraggableContainer } from '../DraggableContainer';
13
- import { InputRadio } from '../InputRadio';
20
+ import { Dropdown, IOptionsProps } from '../Dropdown';
14
21
  import { RPGUIContainerTypes } from '../RPGUI/RPGUIContainer';
15
22
  import { CraftingRecipe } from './CraftingRecipe';
16
23
  import { calculateMaxCraftable } from './utils/calculateMaxCraftable';
@@ -49,6 +56,8 @@ const mobilePortrait = {
49
56
  height: '700px',
50
57
  };
51
58
 
59
+ const ITEMS_PER_PAGE = 8;
60
+
52
61
  export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
53
62
  atlasIMG,
54
63
  atlasJSON,
@@ -67,8 +76,14 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
67
76
  savedSelectedType ?? Object.keys(ItemSubType)[0]
68
77
  );
69
78
  const [size, setSize] = useState<{ width: string; height: string }>();
70
-
79
+ const [searchTerm, setSearchTerm] = useState('');
80
+ const [isSearchVisible, setIsSearchVisible] = useState(false);
71
81
  const [isCraftingDisabled, setIsCraftingDisabled] = useState(false);
82
+ const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(
83
+ 'pinnedCraftItems',
84
+ []
85
+ );
86
+ const [currentPage, setCurrentPage] = useState(1);
72
87
 
73
88
  useEffect(() => {
74
89
  const handleResize = (): void => {
@@ -94,62 +109,62 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
94
109
  return () => window.removeEventListener('resize', handleResize);
95
110
  }, [scale]);
96
111
 
97
- const renderItemTypes = () => {
98
- const itemTypes = ['Suggested', ...Object.keys(ItemSubType)]
99
- .filter(type => type !== 'DeadBody')
100
- .sort((a, b) => {
101
- if (a === 'Suggested') return -1;
102
- if (b === 'Suggested') return 1;
103
- return a.localeCompare(b);
104
- });
105
-
106
- if (window.innerWidth > parseInt(mobilePortrait.width)) {
107
- return itemTypes.map(type => {
108
- return (
109
- <InputRadio
110
- key={type}
111
- value={type}
112
- label={type}
113
- name={type}
114
- isChecked={selectedType === type}
115
- onRadioSelect={value => {
116
- setSelectedType(value);
117
- onSelect(value);
118
- }}
119
- />
120
- );
121
- });
122
- }
123
-
124
- const rows: JSX.Element[][] = [[], []];
125
-
126
- itemTypes.forEach((type, index) => {
127
- let row = 0;
128
-
129
- if (index % 2 === 1) row = 1;
130
-
131
- rows[row].push(
132
- <InputRadio
133
- key={type}
134
- value={type}
135
- label={type}
136
- name={type}
137
- isChecked={selectedType === type}
138
- onRadioSelect={value => {
139
- setSelectedType(value);
140
- onSelect(value);
141
- }}
142
- />
143
- );
144
- });
145
-
146
- return rows.map((row, index) => (
147
- <div key={index} style={{ display: 'flex', gap: '10px' }}>
148
- {row}
149
- </div>
150
- ));
112
+ const togglePinItem = (itemKey: string) => {
113
+ setPinnedItems(current =>
114
+ current.includes(itemKey)
115
+ ? current.filter(key => key !== itemKey)
116
+ : [...current, itemKey]
117
+ );
151
118
  };
152
119
 
120
+ const categoryOptions: IOptionsProps[] = [
121
+ 'Suggested',
122
+ ...(pinnedItems.length > 0 ? ['Pinned'] : []),
123
+ ...Object.keys(ItemSubType),
124
+ ]
125
+ .filter(type => type !== 'DeadBody')
126
+ .sort((a, b) => {
127
+ if (a === 'Suggested') return -1;
128
+ if (b === 'Suggested') return 1;
129
+ if (a === 'Pinned') return -1;
130
+ if (b === 'Pinned') return 1;
131
+ return a.localeCompare(b);
132
+ })
133
+ .map((type, index) => ({
134
+ id: index,
135
+ value: type,
136
+ option: type,
137
+ }));
138
+
139
+ const filteredCraftableItems = craftablesItems?.filter(item => {
140
+ const matchesSearch = item.name
141
+ .toLowerCase()
142
+ .includes(searchTerm.toLowerCase());
143
+ const matchesCategory =
144
+ selectedType === 'Suggested' ||
145
+ (selectedType === 'Pinned' && pinnedItems.includes(item.key)) ||
146
+ item.type === selectedType;
147
+ return matchesSearch && matchesCategory;
148
+ });
149
+
150
+ const sortedItems = [...(filteredCraftableItems || [])].sort((a, b) => {
151
+ const aIsPinned = pinnedItems.includes(a.key);
152
+ const bIsPinned = pinnedItems.includes(b.key);
153
+ if (aIsPinned && !bIsPinned) return -1;
154
+ if (!aIsPinned && bIsPinned) return 1;
155
+ return 0;
156
+ });
157
+
158
+ const totalPages = Math.ceil(sortedItems.length / ITEMS_PER_PAGE);
159
+ const paginatedItems = sortedItems.slice(
160
+ (currentPage - 1) * ITEMS_PER_PAGE,
161
+ currentPage * ITEMS_PER_PAGE
162
+ );
163
+
164
+ useEffect(() => {
165
+ setCurrentPage(1);
166
+ }, [selectedType, searchTerm]);
167
+
153
168
  if (!size) return null;
154
169
 
155
170
  return (
@@ -158,43 +173,95 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
158
173
  width={size.width}
159
174
  height={size.height}
160
175
  cancelDrag=".inputRadioCraftBook"
161
- onCloseButton={() => {
162
- if (onClose) {
163
- onClose();
164
- }
165
- }}
176
+ onCloseButton={onClose}
166
177
  scale={scale}
167
178
  >
168
179
  <Wrapper>
169
- <div style={{ width: '100%' }}>
180
+ <HeaderContainer>
170
181
  <Title>Craftbook</Title>
171
- <Subtitle>Select an item to craft</Subtitle>
172
- <hr className="golden" />
173
- </div>
182
+ <HeaderControls>
183
+ <DropdownWrapper>
184
+ <Dropdown
185
+ options={categoryOptions}
186
+ onChange={value => {
187
+ setSelectedType(value);
188
+ onSelect(value);
189
+ }}
190
+ width="200px"
191
+ />
192
+ </DropdownWrapper>
193
+ <SearchButton onClick={() => setIsSearchVisible(!isSearchVisible)}>
194
+ <FaSearch size={16} />
195
+ </SearchButton>
196
+ </HeaderControls>
197
+ </HeaderContainer>
198
+
199
+ {isSearchVisible && (
200
+ <SearchContainer>
201
+ <input
202
+ type="text"
203
+ className="rpgui-input"
204
+ placeholder="Search items..."
205
+ value={searchTerm}
206
+ onChange={e => setSearchTerm(e.target.value)}
207
+ autoFocus
208
+ />
209
+ </SearchContainer>
210
+ )}
174
211
 
175
212
  <ContentContainer>
176
- <ItemTypes className="inputRadioCraftBook">
177
- {renderItemTypes()}
178
- </ItemTypes>
179
-
180
213
  <RadioInputScroller className="inputRadioCraftBook">
181
- {craftablesItems?.map(item => (
182
- <CraftingRecipe
214
+ {paginatedItems?.map(item => (
215
+ <CraftingRecipeWrapper
183
216
  key={item.key}
184
- atlasIMG={atlasIMG}
185
- atlasJSON={atlasJSON}
186
- equipmentSet={equipmentSet}
187
- recipe={item}
188
- scale={scale}
189
- handleRecipeSelect={setCraftItemKey.bind(null, item.key)}
190
- selectedCraftItemKey={craftItemKey}
191
- inventory={inventory}
192
- skills={skills}
193
- />
217
+ isSelected={pinnedItems.includes(item.key)}
218
+ >
219
+ <PinButton
220
+ onClick={e => {
221
+ e.stopPropagation();
222
+ togglePinItem(item.key);
223
+ }}
224
+ isPinned={pinnedItems.includes(item.key)}
225
+ >
226
+ <FaThumbtack size={14} />
227
+ </PinButton>
228
+ <CraftingRecipe
229
+ atlasIMG={atlasIMG}
230
+ atlasJSON={atlasJSON}
231
+ equipmentSet={equipmentSet}
232
+ recipe={item}
233
+ scale={scale}
234
+ handleRecipeSelect={setCraftItemKey.bind(null, item.key)}
235
+ selectedCraftItemKey={craftItemKey}
236
+ inventory={inventory}
237
+ skills={skills}
238
+ />
239
+ </CraftingRecipeWrapper>
194
240
  ))}
195
241
  </RadioInputScroller>
196
242
  </ContentContainer>
197
- <ButtonWrapper>
243
+
244
+ <PaginationContainer>
245
+ <PaginationButton
246
+ onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
247
+ disabled={currentPage === 1}
248
+ >
249
+ <FaChevronLeft size={12} />
250
+ </PaginationButton>
251
+ <PageInfo>
252
+ Page {currentPage} of {totalPages}
253
+ </PageInfo>
254
+ <PaginationButton
255
+ onClick={() =>
256
+ setCurrentPage(prev => Math.min(totalPages, prev + 1))
257
+ }
258
+ disabled={currentPage === totalPages}
259
+ >
260
+ <FaChevronRight size={12} />
261
+ </PaginationButton>
262
+ </PaginationContainer>
263
+
264
+ <Footer>
198
265
  <Button buttonType={ButtonTypes.RPGUIButton} onPointerDown={onClose}>
199
266
  Cancel
200
267
  </Button>
@@ -222,7 +289,7 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
222
289
  >
223
290
  Craft
224
291
  </Button>
225
- </ButtonWrapper>
292
+ </Footer>
226
293
  </Wrapper>
227
294
  </DraggableContainer>
228
295
  );
@@ -233,44 +300,156 @@ const Wrapper = styled.div`
233
300
  flex-direction: column;
234
301
  width: 100%;
235
302
  height: 100%;
303
+
304
+ & > * {
305
+ box-sizing: border-box;
306
+ }
307
+ `;
308
+
309
+ const HeaderContainer = styled.div`
310
+ display: flex;
311
+ justify-content: space-between;
312
+ align-items: center;
313
+ width: 100%;
314
+ padding: 16px 16px 0;
236
315
  `;
237
316
 
238
317
  const Title = styled.h1`
239
- font-size: 0.6rem;
318
+ font-size: 1.2rem;
240
319
  color: ${uiColors.yellow} !important;
320
+ margin: 0;
321
+ text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
241
322
  `;
242
323
 
243
- const Subtitle = styled.h1`
244
- font-size: 0.4rem;
245
- color: ${uiColors.yellow} !important;
324
+ const HeaderControls = styled.div`
325
+ display: flex;
326
+ align-items: center;
327
+ gap: 16px;
328
+ position: relative;
329
+ left: -2rem;
246
330
  `;
247
331
 
248
- const RadioInputScroller = styled.div`
249
- padding-left: 15px;
250
- padding-top: 10px;
251
- margin-top: 1rem;
332
+ const DropdownWrapper = styled.div`
333
+ width: 200px;
334
+ `;
335
+
336
+ const SearchButton = styled.button`
337
+ background: none;
338
+ border: none;
339
+ cursor: pointer;
340
+ padding: 8px;
341
+ width: 32px;
342
+ height: 32px;
343
+ color: ${uiColors.yellow};
344
+ opacity: 0.8;
345
+ transition: opacity 0.2s;
346
+ display: flex;
252
347
  align-items: center;
253
- align-items: flex-start;
254
- overflow-y: scroll;
255
- min-height: 0;
348
+ justify-content: center;
349
+
350
+ &:hover {
351
+ opacity: 1;
352
+ }
353
+ `;
354
+
355
+ const SearchContainer = styled.div`
356
+ padding: 0 16px;
357
+ margin-top: 16px;
358
+
359
+ input {
360
+ width: 100%;
361
+ font-size: 0.8rem;
362
+ padding: 8px 12px;
363
+ background-color: rgba(0, 0, 0, 0.3);
364
+ border: none;
365
+ color: white;
366
+ border-radius: 4px;
367
+
368
+ &::placeholder {
369
+ color: rgba(255, 255, 255, 0.5);
370
+ }
371
+
372
+ &:focus {
373
+ outline: none;
374
+ background-color: rgba(0, 0, 0, 0.4);
375
+ }
376
+ }
377
+ `;
378
+
379
+ const ContentContainer = styled.div`
256
380
  flex: 1;
257
- margin-left: 10px;
258
- -webkit-overflow-scrolling: touch;
381
+ min-height: 0;
382
+ padding: 16px;
383
+ padding-right: 0;
384
+ padding-bottom: 0;
385
+ overflow: hidden;
386
+ width: 100%;
387
+ `;
388
+
389
+ const RadioInputScroller = styled.div`
390
+ height: 100%;
391
+ overflow-y: scroll;
392
+ overflow-x: hidden;
393
+ padding: 8px 16px;
394
+ padding-right: 24px;
395
+ width: 100%;
396
+ box-sizing: border-box;
397
+
398
+ display: grid;
399
+ grid-template-columns: repeat(2, minmax(0, 1fr));
400
+ gap: 16px;
401
+ align-items: start;
259
402
 
260
403
  @media (max-width: ${mobilePortrait.width}) {
261
- margin-left: 0;
404
+ grid-template-columns: 1fr;
405
+ }
406
+ `;
407
+
408
+ const CraftingRecipeWrapper = styled.div<{ isSelected: boolean }>`
409
+ position: relative;
410
+ width: 100%;
411
+ min-width: 0;
412
+ box-sizing: border-box;
413
+ transition: background-color 0.2s;
414
+
415
+ &:hover {
416
+ background: rgba(0, 0, 0, 0.4);
262
417
  }
418
+
419
+ ${props =>
420
+ props.isSelected &&
421
+ `
422
+ background: rgba(255, 215, 0, 0.1);
423
+ `}
263
424
  `;
264
425
 
265
- const ButtonWrapper = styled.div`
426
+ const PinButton = styled.button<{ isPinned: boolean }>`
427
+ position: absolute;
428
+ top: 8px;
429
+ right: 8px;
430
+ background: none;
431
+ border: none;
432
+ cursor: pointer;
433
+ padding: 4px;
434
+ color: ${props => (props.isPinned ? uiColors.yellow : uiColors.lightGray)};
435
+ opacity: ${props => (props.isPinned ? 1 : 0.6)};
436
+ transition: all 0.2s;
437
+ z-index: 2;
438
+
439
+ &:hover {
440
+ opacity: 1;
441
+ color: ${uiColors.yellow};
442
+ }
443
+ `;
444
+
445
+ const Footer = styled.div`
266
446
  display: flex;
267
447
  justify-content: flex-end;
268
- margin-top: 10px;
269
- width: 100%;
448
+ gap: 16px;
449
+ padding: 8px;
270
450
 
271
451
  button {
272
- padding: 0px 50px;
273
- margin: 5px;
452
+ min-width: 100px;
274
453
  }
275
454
 
276
455
  @media (max-width: ${mobilePortrait.width}) {
@@ -278,29 +457,35 @@ const ButtonWrapper = styled.div`
278
457
  }
279
458
  `;
280
459
 
281
- const ContentContainer = styled.div`
460
+ const PaginationContainer = styled.div`
282
461
  display: flex;
283
- width: 100%;
284
- min-height: 0;
285
- flex: 1;
286
-
287
- @media (max-width: ${mobilePortrait.width}) {
288
- flex-direction: column;
289
- }
462
+ align-items: center;
463
+ justify-content: center;
464
+ gap: 16px;
465
+ padding: 8px;
466
+ margin-top: 8px;
467
+ border-top: 1px solid ${uiColors.darkGray};
290
468
  `;
291
469
 
292
- const ItemTypes = styled.div`
470
+ const PaginationButton = styled.button<{ disabled?: boolean }>`
471
+ background: none;
472
+ border: none;
473
+ color: ${props => (props.disabled ? uiColors.darkGray : uiColors.yellow)};
474
+ cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
475
+ opacity: ${props => (props.disabled ? 0.5 : 0.8)};
476
+ padding: 4px;
293
477
  display: flex;
294
- overflow-y: scroll;
295
- overflow-x: hidden;
296
- width: max-content;
297
- flex-direction: column;
298
- padding-right: 5px;
478
+ align-items: center;
479
+ justify-content: center;
480
+ transition: opacity 0.2s;
299
481
 
300
- @media (max-width: ${mobilePortrait.width}) {
301
- overflow-x: scroll;
302
- overflow-y: hidden;
303
- padding-right: 0;
304
- width: 100%;
482
+ &:hover:not(:disabled) {
483
+ opacity: 1;
305
484
  }
306
485
  `;
486
+
487
+ const PageInfo = styled.div`
488
+ color: ${uiColors.lightGray};
489
+ font-size: 0.8rem;
490
+ font-family: 'Press Start 2P', cursive;
491
+ `;