Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions client/src/components/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ async function quickAddCard(cardId, deckId) {
}
}

export async function showCardDetail(cardId) {
export async function showCardDetail(cardId, printingId = null) {
try {
showLoading();
const [cardResult, ownershipResult] = await Promise.all([
Expand All @@ -596,7 +596,11 @@ export async function showCardDetail(cardId) {
]);
const card = cardResult.card;
const ownership = ownershipResult;
const firstPrinting = card.printings && card.printings.length > 0 ? card.printings[0] : null;
const firstPrinting = card.printings && card.printings.length > 0
? (printingId !== null
? (card.printings.find(p => p.id == printingId) || card.printings[0])
: card.printings[0])
: null;
hideLoading();

// Parse type line to extract supertypes, types, and subtypes
Expand Down Expand Up @@ -1461,16 +1465,13 @@ async function showSwapPrintingModal(card, fromPrintingId, quantity, cardId, own

try {
showLoading();
// Remove from old printing
await api.setOwnedPrintingQuantity(fromPrintingId, 0);
// Add to new printing (will add to existing if already owned)
const existingOwned = ownership.ownedPrintings.find(op => op.printing_id === toPrintingId);
const newQuantity = (existingOwned ? existingOwned.quantity : 0) + quantity;
await api.setOwnedPrintingQuantity(toPrintingId, newQuantity);
// Move inventory and matching deck usage together in one transaction.
await api.swapOwnedPrinting(fromPrintingId, toPrintingId);

showToast('Printing changed!', 'success', 2000);
// Reload card detail
await showCardDetail(cardId);
window.dispatchEvent(new CustomEvent('inventory:refresh'));
// Reload card detail using the newly selected printing
await showCardDetail(cardId, toPrintingId);
hideLoading();
} catch (error) {
hideLoading();
Expand Down
5 changes: 3 additions & 2 deletions client/src/components/deckBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -1738,7 +1738,7 @@ async function showCardModal(printingId) {
try {
const card = currentDeck.cards.find(c => c.printing_id == printingId);
if (card && card.card_id) {
await showCardDetail(card.card_id);
await showCardDetail(card.card_id, card.printing_id);
} else {
console.error('Card not found or missing card_id:', { printingId, card, currentDeck: currentDeck?.cards?.length });
showToast('Card details not available', 'warning');
Expand Down Expand Up @@ -2476,8 +2476,9 @@ function renderExampleHand() {
// Click to show modal
cardEl.addEventListener('click', async () => {
const cardId = cardEl.dataset.cardId;
const printingId = cardEl.dataset.printingId;
if (cardId) {
await showCardDetail(cardId);
await showCardDetail(cardId, printingId || null);
}
});

Expand Down
9 changes: 6 additions & 3 deletions client/src/components/inventory.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ let selectedCards = new Set(); // Track selected card IDs for multi-select
let selectMode = false; // Whether multi-select mode is active

export function setupInventory() {
// Load inventory data when page is shown
// Load inventory data when page is shown or when a printing swap changes ownership.
window.addEventListener('page:inventory', loadInventoryData);
window.addEventListener('inventory:refresh', loadInventoryData);

// Setup filter listeners
setupFilterListeners();
Expand Down Expand Up @@ -887,7 +888,8 @@ function renderGridView(container) {
cb.innerHTML = `<i class="ph ${selectedCards.has(cardId) ? 'ph-check-square' : 'ph-square'}"></i>`;
}
} else {
await showCardDetail(cardId);
const card = inventoryData.cards.find(c => c.card_id === cardId);
await showCardDetail(cardId, card?.selected_printing_id || null);
}
});
});
Expand Down Expand Up @@ -960,7 +962,8 @@ function renderListView(container) {
cb.innerHTML = `<i class="ph ${selectedCards.has(cardId) ? 'ph-check-square' : 'ph-square'}"></i>`;
}
} else {
await showCardDetail(cardId);
const card = inventoryData.cards.find(c => c.card_id === cardId);
await showCardDetail(cardId, card?.selected_printing_id || null);
}
});
});
Expand Down
7 changes: 7 additions & 0 deletions client/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ class ApiClient {
});
}

async swapOwnedPrinting(fromPrintingId, replacementPrintingId) {
return this.request(`/cards/printings/${fromPrintingId}/swap`, {
method: 'POST',
body: JSON.stringify({ replacementPrintingId }),
});
}

// Deck methods
async getDecks() {
return this.request('/decks');
Expand Down
21 changes: 21 additions & 0 deletions src/routes/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getCardOwnershipStatus,
getCardOwnedPrintings,
setOwnedPrintingQuantity,
swapOwnedPrinting,
getCardOwnershipAndUsage,
} from '../services/cardService.js';
import { authenticate } from '../middleware/auth.js';
Expand Down Expand Up @@ -242,6 +243,26 @@ router.get('/:id/ownership-usage', authenticate, (req, res, next) => {
}
});

/**
* POST /api/cards/printings/:printingId/swap
* Atomically move owned copies to another printing
*/
router.post('/printings/:printingId/swap', authenticate, (req, res, next) => {
try {
const fromPrintingId = parseInt(req.params.printingId);
const toPrintingId = parseInt(req.body.replacementPrintingId);

if (!Number.isInteger(toPrintingId)) {
return res.status(400).json({ error: 'replacementPrintingId is required' });
}

const result = swapOwnedPrinting(req.user.id, fromPrintingId, toPrintingId);
res.json(result);
} catch (error) {
next(error);
}
});

/**
* POST /api/cards/printings/:printingId/quantity
* Set owned quantity for a specific printing
Expand Down
117 changes: 117 additions & 0 deletions src/services/cardService.js
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,123 @@ export function setOwnedPrintingQuantity(userId, printingId, quantity) {
return { success: true, quantity };
}

/**
* Atomically move all owned copies from one printing to another.
* Deck rows that explicitly use the old owned printing move with it.
*/
export function swapOwnedPrinting(userId, fromPrintingId, toPrintingId) {
if (fromPrintingId === toPrintingId) {
throw new Error('Replacement printing must be different');
}

const source = db.get(
`SELECT op.id as owned_printing_id, op.quantity, p.card_id
FROM owned_printings op
JOIN printings p ON op.printing_id = p.id
WHERE op.user_id = ? AND op.printing_id = ?`,
[userId, fromPrintingId]
);

if (!source) {
throw new Error('Owned printing not found');
}

const replacement = db.get(
`SELECT card_id FROM printings WHERE id = ?`,
[toPrintingId]
);

if (!replacement || replacement.card_id !== source.card_id) {
throw new Error('Replacement printing must be for the same card');
}

return db.transaction(() => {
const affectedDeckRows = db.all(
`SELECT dc.id, dc.deck_id, dc.quantity, dc.is_sideboard, dc.is_commander,
COALESCE(dc.board_type, CASE WHEN dc.is_sideboard = 1 THEN 'sideboard' ELSE 'mainboard' END) as board_type
FROM deck_cards dc
JOIN decks d ON d.id = dc.deck_id
WHERE dc.printing_id = ? AND d.user_id = ?`,
[fromPrintingId, userId]
);

for (const deckRow of affectedDeckRows) {
const existingDeckRow = db.get(
`SELECT id, quantity, is_commander,
COALESCE(board_type, CASE WHEN is_sideboard = 1 THEN 'sideboard' ELSE 'mainboard' END) as board_type
FROM deck_cards
WHERE deck_id = ? AND printing_id = ? AND is_sideboard = ?`,
[deckRow.deck_id, toPrintingId, deckRow.is_sideboard]
);

if (existingDeckRow) {
// Legacy uniqueness is based on is_sideboard. Do not merge two logical
// boards (for example mainboard and maybeboard) that share that flag.
if (existingDeckRow.board_type !== deckRow.board_type) {
continue;
}

db.run(
`UPDATE deck_cards
SET quantity = ?, is_commander = ?
WHERE id = ?`,
[
existingDeckRow.quantity + deckRow.quantity,
existingDeckRow.is_commander || deckRow.is_commander ? 1 : 0,
existingDeckRow.id
]
);
db.run(`DELETE FROM deck_cards WHERE id = ?`, [deckRow.id]);
} else {
db.run(
`UPDATE deck_cards SET printing_id = ? WHERE id = ?`,
[toPrintingId, deckRow.id]
);
}
}

const existingReplacement = db.get(
`SELECT id, quantity
FROM owned_printings
WHERE user_id = ? AND printing_id = ?`,
[userId, toPrintingId]
);

if (existingReplacement) {
db.run(
`UPDATE owned_printings
SET quantity = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[existingReplacement.quantity + source.quantity, existingReplacement.id]
);
} else {
db.run(
`INSERT INTO owned_printings (user_id, printing_id, quantity)
VALUES (?, ?, ?)`,
[userId, toPrintingId, source.quantity]
);
}

db.run(
`DELETE FROM owned_printings WHERE id = ?`,
[source.owned_printing_id]
);

db.run(
`INSERT INTO owned_cards (user_id, card_id, quantity) VALUES (?, ?, 1)
ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = 1`,
[userId, source.card_id]
);

return {
success: true,
fromPrintingId,
toPrintingId,
quantity: source.quantity
};
});
}

/**
* Get all decks that contain a specific card
*/
Expand Down
15 changes: 13 additions & 2 deletions src/services/inventoryService.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function getInventory(userId, filters = {}) {
} = filters;

const offset = (page - 1) * limit;
const params = [userId];
const params = [userId, userId, userId];
const countParams = [userId];

// Base query - get all owned cards with their details
Expand All @@ -30,7 +30,18 @@ export function getInventory(userId, filters = {}) {
c.colors,
c.type_line,
c.oracle_text,
(SELECT p.image_url FROM printings p WHERE p.card_id = c.id AND p.image_url IS NOT NULL LIMIT 1) as image_url,
(SELECT p.image_url
FROM owned_printings op
JOIN printings p ON op.printing_id = p.id
WHERE op.user_id = ? AND p.card_id = c.id AND p.image_url IS NOT NULL
ORDER BY op.quantity DESC, op.id ASC
LIMIT 1) as image_url,
(SELECT p.id
FROM owned_printings op
JOIN printings p ON op.printing_id = p.id
WHERE op.user_id = ? AND p.card_id = c.id AND p.image_url IS NOT NULL
ORDER BY op.quantity DESC, op.id ASC
LIMIT 1) as selected_printing_id,
(
SELECT COALESCE(SUM(op.quantity), 0)
FROM owned_printings op
Expand Down