Skip to content

Commit 990620e

Browse files
hh
1 parent 2328731 commit 990620e

9 files changed

Lines changed: 898 additions & 20 deletions

File tree

components/Yugioh/GridView.js

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const formatCurrency = ( value ) => {
1313
return `$${ numeric.toFixed( 2 ) }`;
1414
};
1515

16-
const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, handleSortChange } ) => {
16+
const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, handleSortChange, selectedCardIds, onSelectedCardIdsChange, folderNameMap = {} } ) => {
1717
const [ edit, setEdit ] = useState( {} );
1818
const [ editValues, setEditValues ] = useState( {} );
1919
const [ notification, setNotification ] = useState( { show: false, message: "" } );
@@ -25,6 +25,15 @@ const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, han
2525
const sortDirection = isExternallySorted
2626
? ( sortConfig.direction === "descending" ? "desc" : "asc" )
2727
: internalSortDirection;
28+
const isSelectionEnabled = selectedCardIds instanceof Set && typeof onSelectedCardIdsChange === "function";
29+
const updateSelectedRows = useCallback(
30+
( updater ) => {
31+
if ( isSelectionEnabled ) {
32+
onSelectedCardIdsChange( updater );
33+
}
34+
},
35+
[ isSelectionEnabled, onSelectedCardIdsChange ],
36+
);
2837
const displayInputClasses =
2938
"min-w-[4rem] rounded border border-white/30 bg-black/50 px-3 py-1 text-center font-semibold text-white transition hover:border-white/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/60";
3039
const displayInputClassesRose =
@@ -106,6 +115,13 @@ const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, han
106115
const getFullImagePath = useCallback( ( cardId ) => `/images/yugiohImages/${ String( cardId ) }.jpg`, [] );
107116

108117

118+
const getFolderLabels = useCallback(
119+
( card ) => ( Array.isArray( card?.folderIds ) ? card.folderIds : [] )
120+
.map( ( folderId ) => folderNameMap[ String( folderId ) ] )
121+
.filter( Boolean ),
122+
[ folderNameMap ],
123+
);
124+
109125
const memoizedAggregatedData = useMemo( () => {
110126
if ( !Array.isArray( aggregatedData ) ) return [];
111127

@@ -203,8 +219,11 @@ const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, han
203219

204220
const cardIdImage = card.cardId ? getFullImagePath( card.cardId ) : null;
205221
const imageSrc = cardIdImage || card.remoteImageUrl || FALLBACK_IMAGE;
222+
const cardId = String( card._id ?? "" );
223+
const isSelected = isSelectionEnabled && selectedCardIds.has( cardId );
206224
const isFlipped = Boolean( flippedCards[ card._id ] );
207225
const quantity = Number( card.quantity ) || 0;
226+
const folderLabels = getFolderLabels( card );
208227
const totalMarketPrice = ( Number( card.marketPrice ) || 0 ) * quantity;
209228
const removeAmount = editValues[ card._id ]?.deleteAmount || 1;
210229

@@ -214,6 +233,7 @@ const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, han
214233
{ label: "Rarity", value: card.rarity },
215234
{ label: "Printing", value: card.printing },
216235
{ label: "Condition", value: card.condition },
236+
{ label: "Folders", value: folderLabels.join( ", " ) },
217237
{ label: "Old Price", value: formatCurrency( card.oldPrice ) },
218238
{ label: "Market Price", value: formatCurrency( card.marketPrice ) },
219239
{ label: "Total Market Price", value: quantity > 1 ? formatCurrency( totalMarketPrice ) : null },
@@ -275,8 +295,36 @@ const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, han
275295
return (
276296
<div
277297
key={ card._id }
278-
className="group relative mx-auto flex h-full min-h-96 w-full max-w-[16rem] flex-col rounded-[6px] transition"
298+
className={ `group relative mx-auto flex h-full min-h-96 w-full max-w-[16rem] flex-col rounded-[6px] transition ${ isSelected ? "ring-2 ring-indigo-300/70 ring-offset-2 ring-offset-black/60" : "" }` }
279299
>
300+
{ isSelectionEnabled && (
301+
<label
302+
className="absolute left-2 top-2 z-20 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/20 bg-black/75 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-white shadow-lg transition hover:border-indigo-300/70"
303+
onClick={ ( event ) => event.stopPropagation() }
304+
onKeyDown={ ( event ) => event.stopPropagation() }
305+
>
306+
<input
307+
type="checkbox"
308+
className="size-4 cursor-pointer accent-indigo-500"
309+
checked={ isSelected }
310+
disabled={ !cardId }
311+
onChange={ ( event ) => {
312+
const { checked } = event.target;
313+
updateSelectedRows( ( current ) => {
314+
const next = new Set( current );
315+
if ( checked ) {
316+
next.add( cardId );
317+
} else {
318+
next.delete( cardId );
319+
}
320+
return next;
321+
} );
322+
} }
323+
aria-label="Select card"
324+
/>
325+
Select
326+
</label>
327+
) }
280328
<div className="relative mx-auto w-full max-w-[16rem] [perspective:1500px]">
281329
<div
282330
className={ `grid w-full transition-transform duration-[800ms] [transform-style:preserve-3d] [transition-timing-function:cubic-bezier(0.75,0,0.85,1)] ${ isFlipped ? "[transform:rotateY(180deg)]" : "" }` }
@@ -325,8 +373,13 @@ const GridView = ( { aggregatedData, onDeleteCard, onUpdateCard, sortConfig, han
325373
</div>
326374
) }
327375
<p className="text-xs font-medium uppercase tracking-wide">
328-
{ [ card.rarity, card.printing ].filter( Boolean ).join( " " ) }
376+
{ [ card.rarity, card.printing ].filter( Boolean ).join( " / " ) }
329377
</p>
378+
{ folderLabels.length > 0 && (
379+
<p className="line-clamp-1 text-[0.68rem] font-semibold uppercase tracking-wide text-indigo-100">
380+
{ folderLabels.slice( 0, 2 ).join( " / " ) }
381+
</p>
382+
) }
330383
<span className="mt-2 inline-flex min-h-10 w-fit min-w-10 items-center justify-center rounded-[4px] border border-white/35 bg-black/35 px-4 py-2 text-center text-xs font-semibold uppercase leading-tight tracking-[0.08em] text-white shadow-[0_0_6px_rgba(0,0,0,0.3)]">
331384
Details
332385
</span>

components/Yugioh/TableView.js

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,40 @@ const getCardTotalPrice = ( card ) => {
3131
return ( Number.isFinite( unitPrice ) ? unitPrice : 0 ) * ( Number.isFinite( quantity ) ? quantity : 0 );
3232
};
3333

34-
const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfig, handleSortChange } ) => {
34+
const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfig, handleSortChange, selectedCardIds, onSelectedCardIdsChange, folderNameMap = {} } ) => {
3535
const safeCards = Array.isArray( aggregatedData ) ? aggregatedData : [];
3636
const isExternallySorted = Boolean( sortConfig && typeof handleSortChange === 'function' );
3737
const [ internalSortConfig, setInternalSortConfig ] = useState( { key: DEFAULT_SORT_KEY, direction: 'ascending' } );
3838
const activeSortConfig = isExternallySorted ? sortConfig : internalSortConfig;
3939
const [ edit, setEdit ] = useState( {} );
4040
const [ editValues, setEditValues ] = useState( {} );
4141
const [ notification, setNotification ] = useState( { show: false, message: '' } );
42-
const [ selectedRows, setSelectedRows ] = useState( () => new Set() );
42+
const [ internalSelectedRows, setInternalSelectedRows ] = useState( () => new Set() );
4343
const [ lastCheckedId, setLastCheckedId ] = useState( null );
44+
const isSelectionControlled = selectedCardIds instanceof Set && typeof onSelectedCardIdsChange === 'function';
45+
const selectedRows = isSelectionControlled ? selectedCardIds : internalSelectedRows;
46+
const updateSelectedRows = useCallback(
47+
( updater ) => {
48+
if ( isSelectionControlled ) {
49+
onSelectedCardIdsChange( updater );
50+
return;
51+
}
52+
53+
setInternalSelectedRows( updater );
54+
},
55+
[ isSelectionControlled, onSelectedCardIdsChange ],
56+
);
4457
const safeIds = useMemo(
4558
() => safeCards.map( ( card ) => card?._id ).filter( Boolean ),
4659
[ safeCards ],
4760
);
4861

4962
useEffect( () => {
50-
setSelectedRows( ( prev ) => {
63+
if ( isSelectionControlled ) {
64+
return;
65+
}
66+
67+
setInternalSelectedRows( ( prev ) => {
5168
const validIdSet = new Set( safeIds );
5269
let changed = false;
5370
const next = new Set();
@@ -63,7 +80,7 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
6380
}
6481
return next;
6582
} );
66-
}, [ safeIds ] );
83+
}, [ isSelectionControlled, safeIds ] );
6784

6885
const showNotification = useCallback( ( message ) => {
6986
setNotification( { show: true, message } );
@@ -197,7 +214,7 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
197214
( event, cardId ) => {
198215
const { checked } = event.target;
199216
const isShift = event.nativeEvent?.shiftKey;
200-
setSelectedRows( ( prev ) => {
217+
updateSelectedRows( ( prev ) => {
201218
const next = new Set( prev );
202219
if ( isShift && lastCheckedId && lastCheckedId !== cardId ) {
203220
const start = displayedRowIds.indexOf( lastCheckedId );
@@ -219,12 +236,12 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
219236
} );
220237
setLastCheckedId( cardId );
221238
},
222-
[ displayedRowIds, lastCheckedId ],
239+
[ displayedRowIds, lastCheckedId, updateSelectedRows ],
223240
);
224241

225242
const applySelectionToDisplayed = useCallback(
226243
( shouldSelect ) => {
227-
setSelectedRows( ( prev ) => {
244+
updateSelectedRows( ( prev ) => {
228245
const next = new Set( prev );
229246
displayedRowIds.forEach( ( id ) => {
230247
if ( shouldSelect ) next.add( id );
@@ -233,7 +250,7 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
233250
return next;
234251
} );
235252
},
236-
[ displayedRowIds ],
253+
[ displayedRowIds, updateSelectedRows ],
237254
);
238255

239256
const handleHeaderSelectAll = useCallback(
@@ -250,9 +267,9 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
250267
}, [ applySelectionToDisplayed, isAllSelected ] );
251268

252269
const clearSelection = useCallback( () => {
253-
setSelectedRows( new Set() );
270+
updateSelectedRows( () => new Set() );
254271
setLastCheckedId( null );
255-
}, [] );
272+
}, [ updateSelectedRows ] );
256273

257274
const handleBulkDelete = useCallback( async () => {
258275
const selectedIds = Array.from( selectedRows );
@@ -278,6 +295,13 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
278295

279296
const selectedCount = selectedRows.size;
280297

298+
const getFolderLabels = useCallback(
299+
( card ) => ( Array.isArray( card?.folderIds ) ? card.folderIds : [] )
300+
.map( ( folderId ) => folderNameMap[ String( folderId ) ] )
301+
.filter( Boolean ),
302+
[ folderNameMap ],
303+
);
304+
281305
return (
282306
<div className="max-w-full overflow-x-auto">
283307
<Notification
@@ -346,6 +370,9 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
346370
<th onClick={ () => handleSort( 'condition' ) } className="cursor-pointer border border-white/10 px-3 py-2 text-center font-semibold uppercase tracking-wide">
347371
Condition { getSortArrow( 'condition' ) }
348372
</th>
373+
<th className="border border-white/10 px-3 py-2 text-center font-semibold uppercase tracking-wide">
374+
Folders
375+
</th>
349376
<th onClick={ () => handleSort( 'marketPrice' ) } className="cursor-pointer border border-white/10 px-3 py-2 text-center font-semibold uppercase tracking-wide">
350377
Unit Price { getSortArrow( 'marketPrice' ) }
351378
</th>
@@ -443,6 +470,17 @@ const TableView = ( { aggregatedData = [], onDeleteCard, onUpdateCard, sortConfi
443470
<td className="whitespace-nowrap px-3 py-2 text-center sm:text-left">{ card?.printing }</td>
444471
<td className="whitespace-nowrap px-3 py-2 text-center sm:text-left">{ card?.rarity }</td>
445472
<td className="whitespace-nowrap px-3 py-2 text-center sm:text-left">{ card?.condition }</td>
473+
<td className="min-w-40 px-3 py-2 text-center sm:text-left">
474+
<div className="flex flex-wrap justify-center gap-1 sm:justify-start">
475+
{ getFolderLabels( card ).length > 0 ? getFolderLabels( card ).map( ( label ) => (
476+
<span key={ label } className="rounded-full border border-indigo-300/30 bg-indigo-500/15 px-2 py-0.5 text-[0.68rem] font-semibold uppercase tracking-wide text-indigo-50">
477+
{ label }
478+
</span>
479+
) ) : (
480+
<span className="text-white/35">-</span>
481+
) }
482+
</div>
483+
</td>
446484
<td className="whitespace-nowrap px-3 py-2 text-center sm:text-left">{ Number.isFinite( Number( card?.marketPrice ) ) ? Number( card.marketPrice ).toFixed( 2 ) : card?.marketPrice ?? '0.00' }</td>
447485
<td className="whitespace-nowrap px-3 py-2 text-center sm:text-left font-semibold">{ getCardTotalPrice( card ).toFixed( 2 ) }</td>
448486
<td className="flex items-center gap-2 px-3 py-2">

next.config.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ const path = require("path");
33
const nextConfig = {
44
reactStrictMode: true,
55
productionBrowserSourceMaps: false,
6-
experimental: {
7-
webpackBuildWorker: false,
8-
},
96
turbopack: {
107
root: path.join(__dirname),
118
},

pages/api/Yugioh/cards.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export default async function handler( req, res ) {
125125
$setOnInsert: {
126126
marketPrice: card.marketPrice || 0,
127127
lowPrice: card.lowPrice || 0,
128+
folderIds: [],
128129
userId: safeUserId,
129130
createdAt: now
130131
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ObjectId } from "mongodb";
2+
import { requireUser } from "@/proxy/authenticate";
3+
import clientPromise from "@/utils/mongo.js";
4+
import { ensureSafeUserId } from "@/utils/securityValidators.js";
5+
6+
const MAX_CARD_IDS_PER_REQUEST = 500;
7+
8+
const sanitizeObjectIds = ( values ) => {
9+
if ( !Array.isArray( values ) || values.length === 0 ) {
10+
throw new Error( "Select at least one card." );
11+
}
12+
13+
if ( values.length > MAX_CARD_IDS_PER_REQUEST ) {
14+
throw new Error( `You can update up to ${ MAX_CARD_IDS_PER_REQUEST } cards at once.` );
15+
}
16+
17+
return [ ...new Set( values ) ].map( ( value ) => {
18+
if ( typeof value !== "string" || !ObjectId.isValid( value ) ) {
19+
throw new Error( "Invalid card identifier." );
20+
}
21+
22+
return new ObjectId( value );
23+
} );
24+
};
25+
26+
export default async function handler( req, res ) {
27+
if ( req.method !== "PATCH" ) {
28+
res.setHeader( "Allow", [ "PATCH" ] );
29+
return res.status( 405 ).json( { message: `Method ${ req.method } Not Allowed` } );
30+
}
31+
32+
const auth = await requireUser( req, res );
33+
if ( !auth ) {
34+
return;
35+
}
36+
37+
try {
38+
const userId = ensureSafeUserId( auth.decoded.username );
39+
const folderId = req.body?.folderId;
40+
const action = req.body?.action;
41+
42+
if ( typeof folderId !== "string" || !ObjectId.isValid( folderId ) ) {
43+
return res.status( 400 ).json( { message: "Invalid folder identifier." } );
44+
}
45+
46+
if ( action !== "add" && action !== "remove" ) {
47+
return res.status( 400 ).json( { message: "Invalid folder action." } );
48+
}
49+
50+
const cardObjectIds = sanitizeObjectIds( req.body?.cardIds );
51+
const client = await clientPromise;
52+
const db = client.db( "cardPriceApp" );
53+
const folders = db.collection( "collectionFolders" );
54+
const cards = db.collection( "myCollection" );
55+
56+
const folder = await folders.findOne( { _id: new ObjectId( folderId ), userId } );
57+
if ( !folder ) {
58+
return res.status( 404 ).json( { message: "Folder not found." } );
59+
}
60+
61+
const update = action === "add"
62+
? { $addToSet: { folderIds: folderId } }
63+
: { $pull: { folderIds: folderId } };
64+
65+
const result = await cards.updateMany(
66+
{ _id: { $in: cardObjectIds }, userId },
67+
update,
68+
);
69+
70+
return res.status( 200 ).json( {
71+
matchedCount: result.matchedCount,
72+
modifiedCount: result.modifiedCount,
73+
} );
74+
} catch ( error ) {
75+
const status = /select|invalid|update up to/i.test( error.message ) ? 400 : 500;
76+
console.error( "Card folder update error:", error );
77+
return res.status( status ).json( { message: error.message || "Server error" } );
78+
}
79+
}

0 commit comments

Comments
 (0)