@rpg-engine/long-bow 0.7.91 → 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 +448 -293
  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 +448 -294
  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 +281 -138
  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.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,
@@ -68,7 +77,13 @@ 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);
72
87
 
73
88
  useEffect(() => {
74
89
  const handleResize = (): void => {
@@ -94,71 +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
+
153
139
  const filteredCraftableItems = craftablesItems?.filter(item => {
154
140
  const matchesSearch = item.name
155
141
  .toLowerCase()
156
142
  .includes(searchTerm.toLowerCase());
157
143
  const matchesCategory =
158
- selectedType === 'Suggested' || item.type === selectedType;
144
+ selectedType === 'Suggested' ||
145
+ (selectedType === 'Pinned' && pinnedItems.includes(item.key)) ||
146
+ item.type === selectedType;
159
147
  return matchesSearch && matchesCategory;
160
148
  });
161
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
+
162
168
  if (!size) return null;
163
169
 
164
170
  return (
@@ -167,17 +173,30 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
167
173
  width={size.width}
168
174
  height={size.height}
169
175
  cancelDrag=".inputRadioCraftBook"
170
- onCloseButton={() => {
171
- if (onClose) {
172
- onClose();
173
- }
174
- }}
176
+ onCloseButton={onClose}
175
177
  scale={scale}
176
178
  >
177
179
  <Wrapper>
178
- <div style={{ width: '100%' }}>
180
+ <HeaderContainer>
179
181
  <Title>Craftbook</Title>
180
- <Subtitle>Select an item to craft</Subtitle>
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 && (
181
200
  <SearchContainer>
182
201
  <input
183
202
  type="text"
@@ -185,34 +204,64 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
185
204
  placeholder="Search items..."
186
205
  value={searchTerm}
187
206
  onChange={e => setSearchTerm(e.target.value)}
207
+ autoFocus
188
208
  />
189
209
  </SearchContainer>
190
- <hr className="golden" />
191
- </div>
210
+ )}
192
211
 
193
212
  <ContentContainer>
194
- <ItemTypes className="inputRadioCraftBook">
195
- {renderItemTypes()}
196
- </ItemTypes>
197
-
198
213
  <RadioInputScroller className="inputRadioCraftBook">
199
- {filteredCraftableItems?.map(item => (
200
- <CraftingRecipe
214
+ {paginatedItems?.map(item => (
215
+ <CraftingRecipeWrapper
201
216
  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
- />
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>
212
240
  ))}
213
241
  </RadioInputScroller>
214
242
  </ContentContainer>
215
- <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>
216
265
  <Button buttonType={ButtonTypes.RPGUIButton} onPointerDown={onClose}>
217
266
  Cancel
218
267
  </Button>
@@ -240,7 +289,7 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
240
289
  >
241
290
  Craft
242
291
  </Button>
243
- </ButtonWrapper>
292
+ </Footer>
244
293
  </Wrapper>
245
294
  </DraggableContainer>
246
295
  );
@@ -251,81 +300,61 @@ const Wrapper = styled.div`
251
300
  flex-direction: column;
252
301
  width: 100%;
253
302
  height: 100%;
254
- `;
255
-
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
303
 
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;
304
+ & > * {
305
+ box-sizing: border-box;
280
306
  }
281
307
  `;
282
308
 
283
- const ButtonWrapper = styled.div`
309
+ const HeaderContainer = styled.div`
284
310
  display: flex;
285
- justify-content: flex-end;
286
- margin-top: 10px;
311
+ justify-content: space-between;
312
+ align-items: center;
287
313
  width: 100%;
314
+ padding: 16px 16px 0;
315
+ `;
288
316
 
289
- button {
290
- padding: 0px 50px;
291
- margin: 5px;
292
- }
293
-
294
- @media (max-width: ${mobilePortrait.width}) {
295
- justify-content: center;
296
- }
317
+ const Title = styled.h1`
318
+ font-size: 1.2rem;
319
+ color: ${uiColors.yellow} !important;
320
+ margin: 0;
321
+ text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
297
322
  `;
298
323
 
299
- const ContentContainer = styled.div`
324
+ const HeaderControls = styled.div`
300
325
  display: flex;
301
- width: 100%;
302
- min-height: 0;
303
- flex: 1;
326
+ align-items: center;
327
+ gap: 16px;
328
+ position: relative;
329
+ left: -2rem;
330
+ `;
304
331
 
305
- @media (max-width: ${mobilePortrait.width}) {
306
- flex-direction: column;
307
- }
332
+ const DropdownWrapper = styled.div`
333
+ width: 200px;
308
334
  `;
309
335
 
310
- const ItemTypes = styled.div`
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;
311
346
  display: flex;
312
- overflow-y: scroll;
313
- overflow-x: hidden;
314
- width: max-content;
315
- flex-direction: column;
316
- padding-right: 5px;
347
+ align-items: center;
348
+ justify-content: center;
317
349
 
318
- @media (max-width: ${mobilePortrait.width}) {
319
- overflow-x: scroll;
320
- overflow-y: hidden;
321
- padding-right: 0;
322
- width: 100%;
350
+ &:hover {
351
+ opacity: 1;
323
352
  }
324
353
  `;
325
354
 
326
355
  const SearchContainer = styled.div`
327
- margin: 8px 0;
328
356
  padding: 0 16px;
357
+ margin-top: 16px;
329
358
 
330
359
  input {
331
360
  width: 100%;
@@ -346,3 +375,117 @@ const SearchContainer = styled.div`
346
375
  }
347
376
  }
348
377
  `;
378
+
379
+ const ContentContainer = styled.div`
380
+ flex: 1;
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;
402
+
403
+ @media (max-width: ${mobilePortrait.width}) {
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);
417
+ }
418
+
419
+ ${props =>
420
+ props.isSelected &&
421
+ `
422
+ background: rgba(255, 215, 0, 0.1);
423
+ `}
424
+ `;
425
+
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`
446
+ display: flex;
447
+ justify-content: flex-end;
448
+ gap: 16px;
449
+ padding: 8px;
450
+
451
+ button {
452
+ min-width: 100px;
453
+ }
454
+
455
+ @media (max-width: ${mobilePortrait.width}) {
456
+ justify-content: center;
457
+ }
458
+ `;
459
+
460
+ const PaginationContainer = styled.div`
461
+ display: flex;
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};
468
+ `;
469
+
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;
477
+ display: flex;
478
+ align-items: center;
479
+ justify-content: center;
480
+ transition: opacity 0.2s;
481
+
482
+ &:hover:not(:disabled) {
483
+ opacity: 1;
484
+ }
485
+ `;
486
+
487
+ const PageInfo = styled.div`
488
+ color: ${uiColors.lightGray};
489
+ font-size: 0.8rem;
490
+ font-family: 'Press Start 2P', cursive;
491
+ `;