@rpg-engine/long-bow 0.7.91 → 0.7.93

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 +455 -294
  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 +455 -295
  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 +287 -139
  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.91",
3
+ "version": "0.7.93",
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,
@@ -68,7 +77,18 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
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);
87
+ const [items, setItems] = useState<ICraftableItem[]>(craftablesItems);
88
+
89
+ useEffect(() => {
90
+ setItems(craftablesItems);
91
+ }, [craftablesItems]);
72
92
 
73
93
  useEffect(() => {
74
94
  const handleResize = (): void => {
@@ -94,71 +114,62 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
94
114
  return () => window.removeEventListener('resize', handleResize);
95
115
  }, [scale]);
96
116
 
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
- ));
117
+ const togglePinItem = (itemKey: string) => {
118
+ setPinnedItems(current =>
119
+ current.includes(itemKey)
120
+ ? current.filter(key => key !== itemKey)
121
+ : [...current, itemKey]
122
+ );
151
123
  };
152
124
 
153
- const filteredCraftableItems = craftablesItems?.filter(item => {
125
+ const categoryOptions: IOptionsProps[] = [
126
+ 'Suggested',
127
+ ...(pinnedItems.length > 0 ? ['Pinned'] : []),
128
+ ...Object.keys(ItemSubType),
129
+ ]
130
+ .filter(type => type !== 'DeadBody')
131
+ .sort((a, b) => {
132
+ if (a === 'Suggested') return -1;
133
+ if (b === 'Suggested') return 1;
134
+ if (a === 'Pinned') return -1;
135
+ if (b === 'Pinned') return 1;
136
+ return a.localeCompare(b);
137
+ })
138
+ .map((type, index) => ({
139
+ id: index,
140
+ value: type,
141
+ option: type,
142
+ }));
143
+
144
+ const filteredCraftableItems = items?.filter(item => {
154
145
  const matchesSearch = item.name
155
146
  .toLowerCase()
156
147
  .includes(searchTerm.toLowerCase());
157
148
  const matchesCategory =
158
- selectedType === 'Suggested' || item.type === selectedType;
149
+ selectedType === 'Suggested' ||
150
+ (selectedType === 'Pinned' && pinnedItems.includes(item.key)) ||
151
+ item.subType === selectedType;
159
152
  return matchesSearch && matchesCategory;
160
153
  });
161
154
 
155
+ const sortedItems = [...(filteredCraftableItems || [])].sort((a, b) => {
156
+ const aIsPinned = pinnedItems.includes(a.key);
157
+ const bIsPinned = pinnedItems.includes(b.key);
158
+ if (aIsPinned && !bIsPinned) return -1;
159
+ if (!aIsPinned && bIsPinned) return 1;
160
+ return 0;
161
+ });
162
+
163
+ const totalPages = Math.ceil(sortedItems.length / ITEMS_PER_PAGE);
164
+ const paginatedItems = sortedItems.slice(
165
+ (currentPage - 1) * ITEMS_PER_PAGE,
166
+ currentPage * ITEMS_PER_PAGE
167
+ );
168
+
169
+ useEffect(() => {
170
+ setCurrentPage(1);
171
+ }, [selectedType, searchTerm]);
172
+
162
173
  if (!size) return null;
163
174
 
164
175
  return (
@@ -167,17 +178,30 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
167
178
  width={size.width}
168
179
  height={size.height}
169
180
  cancelDrag=".inputRadioCraftBook"
170
- onCloseButton={() => {
171
- if (onClose) {
172
- onClose();
173
- }
174
- }}
181
+ onCloseButton={onClose}
175
182
  scale={scale}
176
183
  >
177
184
  <Wrapper>
178
- <div style={{ width: '100%' }}>
185
+ <HeaderContainer>
179
186
  <Title>Craftbook</Title>
180
- <Subtitle>Select an item to craft</Subtitle>
187
+ <HeaderControls>
188
+ <DropdownWrapper>
189
+ <Dropdown
190
+ options={categoryOptions}
191
+ onChange={value => {
192
+ setSelectedType(value);
193
+ onSelect(value);
194
+ }}
195
+ width="200px"
196
+ />
197
+ </DropdownWrapper>
198
+ <SearchButton onClick={() => setIsSearchVisible(!isSearchVisible)}>
199
+ <FaSearch size={16} />
200
+ </SearchButton>
201
+ </HeaderControls>
202
+ </HeaderContainer>
203
+
204
+ {isSearchVisible && (
181
205
  <SearchContainer>
182
206
  <input
183
207
  type="text"
@@ -185,34 +209,64 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
185
209
  placeholder="Search items..."
186
210
  value={searchTerm}
187
211
  onChange={e => setSearchTerm(e.target.value)}
212
+ autoFocus
188
213
  />
189
214
  </SearchContainer>
190
- <hr className="golden" />
191
- </div>
215
+ )}
192
216
 
193
217
  <ContentContainer>
194
- <ItemTypes className="inputRadioCraftBook">
195
- {renderItemTypes()}
196
- </ItemTypes>
197
-
198
218
  <RadioInputScroller className="inputRadioCraftBook">
199
- {filteredCraftableItems?.map(item => (
200
- <CraftingRecipe
219
+ {paginatedItems?.map(item => (
220
+ <CraftingRecipeWrapper
201
221
  key={item.key}
202
- atlasIMG={atlasIMG}
203
- atlasJSON={atlasJSON}
204
- equipmentSet={equipmentSet}
205
- recipe={item}
206
- scale={scale}
207
- handleRecipeSelect={setCraftItemKey.bind(null, item.key)}
208
- selectedCraftItemKey={craftItemKey}
209
- inventory={inventory}
210
- skills={skills}
211
- />
222
+ isSelected={pinnedItems.includes(item.key)}
223
+ >
224
+ <PinButton
225
+ onClick={e => {
226
+ e.stopPropagation();
227
+ togglePinItem(item.key);
228
+ }}
229
+ isPinned={pinnedItems.includes(item.key)}
230
+ >
231
+ <FaThumbtack size={14} />
232
+ </PinButton>
233
+ <CraftingRecipe
234
+ atlasIMG={atlasIMG}
235
+ atlasJSON={atlasJSON}
236
+ equipmentSet={equipmentSet}
237
+ recipe={item}
238
+ scale={scale}
239
+ handleRecipeSelect={setCraftItemKey.bind(null, item.key)}
240
+ selectedCraftItemKey={craftItemKey}
241
+ inventory={inventory}
242
+ skills={skills}
243
+ />
244
+ </CraftingRecipeWrapper>
212
245
  ))}
213
246
  </RadioInputScroller>
214
247
  </ContentContainer>
215
- <ButtonWrapper>
248
+
249
+ <PaginationContainer>
250
+ <PaginationButton
251
+ onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
252
+ disabled={currentPage === 1}
253
+ >
254
+ <FaChevronLeft size={12} />
255
+ </PaginationButton>
256
+ <PageInfo>
257
+ Page {currentPage} of {totalPages}
258
+ </PageInfo>
259
+ <PaginationButton
260
+ onClick={() =>
261
+ setCurrentPage(prev => Math.min(totalPages, prev + 1))
262
+ }
263
+ disabled={currentPage === totalPages}
264
+ >
265
+ <FaChevronRight size={12} />
266
+ </PaginationButton>
267
+ </PaginationContainer>
268
+
269
+ <Footer>
216
270
  <Button buttonType={ButtonTypes.RPGUIButton} onPointerDown={onClose}>
217
271
  Cancel
218
272
  </Button>
@@ -240,7 +294,7 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
240
294
  >
241
295
  Craft
242
296
  </Button>
243
- </ButtonWrapper>
297
+ </Footer>
244
298
  </Wrapper>
245
299
  </DraggableContainer>
246
300
  );
@@ -251,81 +305,61 @@ const Wrapper = styled.div`
251
305
  flex-direction: column;
252
306
  width: 100%;
253
307
  height: 100%;
254
- `;
255
308
 
256
- const Title = styled.h1`
257
- font-size: 0.6rem;
258
- color: ${uiColors.yellow} !important;
259
- `;
260
-
261
- const Subtitle = styled.h1`
262
- font-size: 0.4rem;
263
- color: ${uiColors.yellow} !important;
264
- `;
265
-
266
- const RadioInputScroller = styled.div`
267
- padding-left: 15px;
268
- padding-top: 10px;
269
- margin-top: 1rem;
270
- align-items: center;
271
- align-items: flex-start;
272
- overflow-y: scroll;
273
- min-height: 0;
274
- flex: 1;
275
- margin-left: 10px;
276
- -webkit-overflow-scrolling: touch;
277
-
278
- @media (max-width: ${mobilePortrait.width}) {
279
- margin-left: 0;
309
+ & > * {
310
+ box-sizing: border-box;
280
311
  }
281
312
  `;
282
313
 
283
- const ButtonWrapper = styled.div`
314
+ const HeaderContainer = styled.div`
284
315
  display: flex;
285
- justify-content: flex-end;
286
- margin-top: 10px;
316
+ justify-content: space-between;
317
+ align-items: center;
287
318
  width: 100%;
319
+ padding: 16px 16px 0;
320
+ `;
288
321
 
289
- button {
290
- padding: 0px 50px;
291
- margin: 5px;
292
- }
293
-
294
- @media (max-width: ${mobilePortrait.width}) {
295
- justify-content: center;
296
- }
322
+ const Title = styled.h1`
323
+ font-size: 1.2rem;
324
+ color: ${uiColors.yellow} !important;
325
+ margin: 0;
326
+ text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
297
327
  `;
298
328
 
299
- const ContentContainer = styled.div`
329
+ const HeaderControls = styled.div`
300
330
  display: flex;
301
- width: 100%;
302
- min-height: 0;
303
- flex: 1;
331
+ align-items: center;
332
+ gap: 16px;
333
+ position: relative;
334
+ left: -2rem;
335
+ `;
304
336
 
305
- @media (max-width: ${mobilePortrait.width}) {
306
- flex-direction: column;
307
- }
337
+ const DropdownWrapper = styled.div`
338
+ width: 200px;
308
339
  `;
309
340
 
310
- const ItemTypes = styled.div`
341
+ const SearchButton = styled.button`
342
+ background: none;
343
+ border: none;
344
+ cursor: pointer;
345
+ padding: 8px;
346
+ width: 32px;
347
+ height: 32px;
348
+ color: ${uiColors.yellow};
349
+ opacity: 0.8;
350
+ transition: opacity 0.2s;
311
351
  display: flex;
312
- overflow-y: scroll;
313
- overflow-x: hidden;
314
- width: max-content;
315
- flex-direction: column;
316
- padding-right: 5px;
352
+ align-items: center;
353
+ justify-content: center;
317
354
 
318
- @media (max-width: ${mobilePortrait.width}) {
319
- overflow-x: scroll;
320
- overflow-y: hidden;
321
- padding-right: 0;
322
- width: 100%;
355
+ &:hover {
356
+ opacity: 1;
323
357
  }
324
358
  `;
325
359
 
326
360
  const SearchContainer = styled.div`
327
- margin: 8px 0;
328
361
  padding: 0 16px;
362
+ margin-top: 16px;
329
363
 
330
364
  input {
331
365
  width: 100%;
@@ -346,3 +380,117 @@ const SearchContainer = styled.div`
346
380
  }
347
381
  }
348
382
  `;
383
+
384
+ const ContentContainer = styled.div`
385
+ flex: 1;
386
+ min-height: 0;
387
+ padding: 16px;
388
+ padding-right: 0;
389
+ padding-bottom: 0;
390
+ overflow: hidden;
391
+ width: 100%;
392
+ `;
393
+
394
+ const RadioInputScroller = styled.div`
395
+ height: 100%;
396
+ overflow-y: scroll;
397
+ overflow-x: hidden;
398
+ padding: 8px 16px;
399
+ padding-right: 24px;
400
+ width: 100%;
401
+ box-sizing: border-box;
402
+
403
+ display: grid;
404
+ grid-template-columns: repeat(2, minmax(0, 1fr));
405
+ gap: 16px;
406
+ align-items: start;
407
+
408
+ @media (max-width: ${mobilePortrait.width}) {
409
+ grid-template-columns: 1fr;
410
+ }
411
+ `;
412
+
413
+ const CraftingRecipeWrapper = styled.div<{ isSelected: boolean }>`
414
+ position: relative;
415
+ width: 100%;
416
+ min-width: 0;
417
+ box-sizing: border-box;
418
+ transition: background-color 0.2s;
419
+
420
+ &:hover {
421
+ background: rgba(0, 0, 0, 0.4);
422
+ }
423
+
424
+ ${props =>
425
+ props.isSelected &&
426
+ `
427
+ background: rgba(255, 215, 0, 0.1);
428
+ `}
429
+ `;
430
+
431
+ const PinButton = styled.button<{ isPinned: boolean }>`
432
+ position: absolute;
433
+ top: 8px;
434
+ right: 8px;
435
+ background: none;
436
+ border: none;
437
+ cursor: pointer;
438
+ padding: 4px;
439
+ color: ${props => (props.isPinned ? uiColors.yellow : uiColors.lightGray)};
440
+ opacity: ${props => (props.isPinned ? 1 : 0.6)};
441
+ transition: all 0.2s;
442
+ z-index: 2;
443
+
444
+ &:hover {
445
+ opacity: 1;
446
+ color: ${uiColors.yellow};
447
+ }
448
+ `;
449
+
450
+ const Footer = styled.div`
451
+ display: flex;
452
+ justify-content: flex-end;
453
+ gap: 16px;
454
+ padding: 8px;
455
+
456
+ button {
457
+ min-width: 100px;
458
+ }
459
+
460
+ @media (max-width: ${mobilePortrait.width}) {
461
+ justify-content: center;
462
+ }
463
+ `;
464
+
465
+ const PaginationContainer = styled.div`
466
+ display: flex;
467
+ align-items: center;
468
+ justify-content: center;
469
+ gap: 16px;
470
+ padding: 8px;
471
+ margin-top: 8px;
472
+ border-top: 1px solid ${uiColors.darkGray};
473
+ `;
474
+
475
+ const PaginationButton = styled.button<{ disabled?: boolean }>`
476
+ background: none;
477
+ border: none;
478
+ color: ${props => (props.disabled ? uiColors.darkGray : uiColors.yellow)};
479
+ cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
480
+ opacity: ${props => (props.disabled ? 0.5 : 0.8)};
481
+ padding: 4px;
482
+ display: flex;
483
+ align-items: center;
484
+ justify-content: center;
485
+ transition: opacity 0.2s;
486
+
487
+ &:hover:not(:disabled) {
488
+ opacity: 1;
489
+ }
490
+ `;
491
+
492
+ const PageInfo = styled.div`
493
+ color: ${uiColors.lightGray};
494
+ font-size: 0.8rem;
495
+ font-family: 'Press Start 2P', cursive;
496
+ `;