From abbe68888d733bfdc42d7a319369469cfb50c196 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 26 Nov 2025 15:34:41 +0100 Subject: [PATCH 1/5] add trade editing functionality and version history tracking --- src/components/Community.tsx | 58 +---- src/components/TradeCreator.tsx | 118 +++++++--- src/components/TradeDetail.tsx | 377 +++++++++++++++++++++++--------- src/services/tradesService.ts | 137 ++++++++++++ 4 files changed, 505 insertions(+), 185 deletions(-) diff --git a/src/components/Community.tsx b/src/components/Community.tsx index 7cd6671..9155986 100644 --- a/src/components/Community.tsx +++ b/src/components/Community.tsx @@ -81,12 +81,6 @@ export default function Community() { const [tradeCardDetails, setTradeCardDetails] = useState>(new Map()); const [processingTradeId, setProcessingTradeId] = useState(null); const [selectedTrade, setSelectedTrade] = useState(null); - const [counterOfferData, setCounterOfferData] = useState<{ - receiverId: string; - receiverUsername: string; - receiverCollection: CollectionItem[]; - initialOffer?: { senderCards: Card[]; receiverCards: Card[] }; - } | null>(null); // Profile state const [username, setUsername] = useState(''); @@ -318,37 +312,6 @@ export default function Community() { }); }; - const handleCounterOffer = async (trade: Trade, senderCards: Card[], receiverCards: Card[]) => { - try { - // Decline the original trade - await declineTrade(trade.id); - - // Load the sender's collection for the counter-offer - const collectionMap = await getUserCollection(trade.sender_id); - const cardIds = Array.from(collectionMap.keys()); - const cards = await getCardsByIds(cardIds); - const senderCollection = cards.map((card) => ({ - card, - quantity: collectionMap.get(card.id) || 0, - })); - - // Set up counter-offer data (swap sender and receiver) - setCounterOfferData({ - receiverId: trade.sender_id, - receiverUsername: trade.sender?.username || 'User', - receiverCollection: senderCollection, - initialOffer: { - senderCards: receiverCards, // What you want to give back - receiverCards: senderCards, // What you want to receive - }, - }); - - await loadTradesData(); - } catch (error) { - console.error('Error setting up counter-offer:', error); - toast.error('Failed to set up counter-offer'); - } - }; // ============ PROFILE FUNCTIONS ============ const loadProfile = async () => { @@ -840,7 +803,8 @@ export default function Community() { pending: 'text-yellow-400', }; - const canViewDetails = !isSender && trade.status === 'pending'; + // Both users can view details for pending trades + const canViewDetails = trade.status === 'pending'; return (
- Tap to view details + {isSender ? 'Tap to view/edit' : 'Tap to view details'}

)} @@ -950,21 +914,9 @@ export default function Community() { onClose={() => setSelectedTrade(null)} onAccept={handleAcceptTrade} onDecline={handleDeclineTrade} - onCounterOffer={handleCounterOffer} - /> - )} - - {/* Counter Offer Creator */} - {counterOfferData && ( - setCounterOfferData(null)} - onTradeCreated={() => { - setCounterOfferData(null); + onTradeUpdated={() => { + setSelectedTrade(null); loadTradesData(); - toast.success('Counter offer sent!'); }} /> )} diff --git a/src/components/TradeCreator.tsx b/src/components/TradeCreator.tsx index c91c4ca..a5effc9 100644 --- a/src/components/TradeCreator.tsx +++ b/src/components/TradeCreator.tsx @@ -3,7 +3,7 @@ import { X, ArrowLeftRight, ArrowRight, ArrowLeft, Minus, Send, Gift, Loader2, S import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { getUserCollection, getCardsByIds } from '../services/api'; -import { createTrade } from '../services/tradesService'; +import { createTrade, updateTrade } from '../services/tradesService'; import { Card } from '../types'; interface CollectionItem { @@ -182,6 +182,11 @@ interface TradeCreatorProps { receiverCollection: CollectionItem[]; onClose: () => void; onTradeCreated: () => void; + editMode?: boolean; + existingTradeId?: string; + initialSenderCards?: Card[]; + initialReceiverCards?: Card[]; + initialMessage?: string; } type MobileStep = 'want' | 'give' | 'review'; @@ -192,13 +197,18 @@ export default function TradeCreator({ receiverCollection, onClose, onTradeCreated, + editMode = false, + existingTradeId, + initialSenderCards = [], + initialReceiverCards = [], + initialMessage = '', }: TradeCreatorProps) { const { user } = useAuth(); const toast = useToast(); const [myCollection, setMyCollection] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); - const [message, setMessage] = useState(''); + const [message, setMessage] = useState(initialMessage); const [isGiftMode, setIsGiftMode] = useState(false); const [mobileStep, setMobileStep] = useState('want'); @@ -222,6 +232,57 @@ export default function TradeCreator({ } }, [isGiftMode]); + // Pre-populate cards in edit mode + useEffect(() => { + if (!editMode || !myCollection.length || !receiverCollection.length) return; + if (initialSenderCards.length === 0 && initialReceiverCards.length === 0) return; + + console.log('Pre-populating cards', { + initialSenderCards: initialSenderCards.length, + initialReceiverCards: initialReceiverCards.length, + myCollection: myCollection.length, + receiverCollection: receiverCollection.length + }); + + // Pre-populate sender cards with their quantities + const senderMap = new Map(); + initialSenderCards.forEach(card => { + const collectionItem = myCollection.find(c => c.card.id === card.id); + if (collectionItem) { + // Find the quantity from trade items if card has quantity property + const quantity = (card as any).quantity || 1; + console.log('Adding sender card:', card.name, 'qty:', quantity); + senderMap.set(card.id, { + card: card, + quantity: quantity, + maxQuantity: collectionItem.quantity, + }); + } else { + console.log('Card not found in my collection:', card.name, card.id); + } + }); + setMyOfferedCards(senderMap); + + // Pre-populate receiver cards with their quantities + const receiverMap = new Map(); + initialReceiverCards.forEach(card => { + const collectionItem = receiverCollection.find(c => c.card.id === card.id); + if (collectionItem) { + // Find the quantity from trade items if card has quantity property + const quantity = (card as any).quantity || 1; + console.log('Adding receiver card:', card.name, 'qty:', quantity); + receiverMap.set(card.id, { + card: card, + quantity: quantity, + maxQuantity: collectionItem.quantity, + }); + } else { + console.log('Card not found in their collection:', card.name, card.id); + } + }); + setWantedCards(receiverMap); + }, [editMode, myCollection, receiverCollection, initialSenderCards, initialReceiverCards]); + const loadMyCollection = async () => { if (!user) return; setLoading(true); @@ -324,18 +385,32 @@ export default function TradeCreator({ quantity: item.quantity, })); - await createTrade({ - senderId: user.id, - receiverId, - message: message || undefined, - senderCards, - receiverCards, - }); + if (editMode && existingTradeId) { + // Update existing trade + await updateTrade({ + tradeId: existingTradeId, + editorId: user.id, + message: message || undefined, + senderCards, + receiverCards, + }); + toast.success('Trade updated!'); + } else { + // Create new trade + await createTrade({ + senderId: user.id, + receiverId, + message: message || undefined, + senderCards, + receiverCards, + }); + toast.success('Trade offer sent!'); + } onTradeCreated(); } catch (error) { - console.error('Error creating trade:', error); - toast.error('Failed to create trade'); + console.error('Error with trade:', error); + toast.error(editMode ? 'Failed to update trade' : 'Failed to create trade'); } finally { setSubmitting(false); } @@ -487,12 +562,6 @@ export default function TradeCreator({ placeholder="Add a message..." className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> - {message && ( -
-

Preview:

-

{message}

-
- )}
)} @@ -559,7 +628,7 @@ export default function TradeCreator({
-

Trade with {receiverUsername}

+

{editMode ? 'Edit Trade' : `Trade with ${receiverUsername}`}

+ )}
)} @@ -260,7 +379,9 @@ export default function TradeDetail({ {/* Actions - Only for pending trades */} {trade.status === 'pending' && !loading && (
- {isReceiver ? ( + {/* Only the user who DIDN'T make the last edit can respond */} + {trade.editor_id && trade.editor_id !== user?.id ? ( + /* User receives the last edit - can accept/decline/counter */ <>
- ) : ( + ) : trade.editor_id === user?.id ? ( + /* User made the last edit - waiting for response */

Waiting for {otherUser?.username} to respond...

+ ) : ( + /* No editor yet (version 1) - original flow */ + <> + {isSender ? ( + + ) : ( + <> +
+ + +
+ + + )} + )}
)} diff --git a/src/services/tradesService.ts b/src/services/tradesService.ts index 41104f5..375ed18 100644 --- a/src/services/tradesService.ts +++ b/src/services/tradesService.ts @@ -16,11 +16,32 @@ export interface Trade { message: string | null; created_at: string | null; updated_at: string | null; + version: number; + editor_id: string | null; sender?: { username: string | null }; receiver?: { username: string | null }; items?: TradeItem[]; } +export interface TradeHistoryEntry { + id: string; + trade_id: string; + version: number; + editor_id: string; + message: string | null; + created_at: string; + editor?: { username: string | null }; + items?: TradeHistoryItem[]; +} + +export interface TradeHistoryItem { + id: string; + history_id: string; + owner_id: string; + card_id: string; + quantity: number; +} + export interface CreateTradeParams { senderId: string; receiverId: string; @@ -29,6 +50,14 @@ export interface CreateTradeParams { receiverCards: { cardId: string; quantity: number }[]; } +export interface UpdateTradeParams { + tradeId: string; + editorId: string; + message?: string; + senderCards: { cardId: string; quantity: number }[]; + receiverCards: { cardId: string; quantity: number }[]; +} + // Get all trades for a user export async function getTrades(userId: string): Promise { const { data, error } = await supabase @@ -182,3 +211,111 @@ export async function getTradeHistory(userId: string): Promise { if (error) throw error; return data as Trade[]; } + +// Update an existing trade (for edits and counter-offers) +export async function updateTrade(params: UpdateTradeParams): Promise { + const { tradeId, editorId, message, senderCards, receiverCards } = params; + + // Get current trade info + const { data: currentTrade, error: tradeError } = await supabase + .from('trades') + .select('version, sender_id, receiver_id') + .eq('id', tradeId) + .single(); + + if (tradeError) throw tradeError; + + const newVersion = (currentTrade.version || 1) + 1; + + // Save current state to history before updating + const { data: historyEntry, error: historyError } = await supabase + .from('trade_history') + .insert({ + trade_id: tradeId, + version: currentTrade.version || 1, + editor_id: editorId, + message: message || null, + }) + .select() + .single(); + + if (historyError) throw historyError; + + // Save current items to history + const { data: currentItems } = await supabase + .from('trade_items') + .select('*') + .eq('trade_id', tradeId); + + if (currentItems && currentItems.length > 0) { + const historyItems = currentItems.map(item => ({ + history_id: historyEntry.id, + owner_id: item.owner_id, + card_id: item.card_id, + quantity: item.quantity, + })); + + await supabase.from('trade_history_items').insert(historyItems); + } + + // Update the trade + const { data: updatedTrade, error: updateError } = await supabase + .from('trades') + .update({ + message, + version: newVersion, + editor_id: editorId, + updated_at: new Date().toISOString(), + }) + .eq('id', tradeId) + .select() + .single(); + + if (updateError) throw updateError; + + // Delete existing items + await supabase.from('trade_items').delete().eq('trade_id', tradeId); + + // Add new items + const senderItems = senderCards.map((card) => ({ + trade_id: tradeId, + owner_id: currentTrade.sender_id, + card_id: card.cardId, + quantity: card.quantity, + })); + + const receiverItems = receiverCards.map((card) => ({ + trade_id: tradeId, + owner_id: currentTrade.receiver_id, + card_id: card.cardId, + quantity: card.quantity, + })); + + const allItems = [...senderItems, ...receiverItems]; + + if (allItems.length > 0) { + const { error: itemsError } = await supabase + .from('trade_items') + .insert(allItems); + + if (itemsError) throw itemsError; + } + + return updatedTrade; +} + +// Get version history for a trade +export async function getTradeVersionHistory(tradeId: string): Promise { + const { data, error } = await supabase + .from('trade_history') + .select(` + *, + editor:profiles!trade_history_editor_id_fkey(username), + items:trade_history_items(*) + `) + .eq('trade_id', tradeId) + .order('version', { ascending: true }); + + if (error) throw error; + return data as TradeHistoryEntry[]; +} -- 2.49.1 From 89fc4a782c5d916c3196104cdfd02942fe1eba6f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 26 Nov 2025 19:12:07 +0100 Subject: [PATCH 2/5] update trade system --- .claude/settings.local.json | 16 ++++--- dev-dist/sw.js | 2 +- src/components/Community.tsx | 18 +++---- src/components/TradeCreator.tsx | 16 +++---- src/components/TradeDetail.tsx | 61 ++++++++++++------------ src/lib/Entities.ts | 33 +++++++++---- src/services/tradesService.ts | 84 ++++++++++++++++++--------------- 7 files changed, 128 insertions(+), 102 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dff52c0..053c3dc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,11 +1,15 @@ { + "permissions": { + "allow": [ + "mcp__supabase__apply_migration", + "mcp__supabase__list_tables", + "mcp__supabase__execute_sql", + "Bash(npm run build:*)", + "mcp__supabase__get_advisors" + ] + }, "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "supabase" - ], - "permissions": { - "allow": [ - "mcp__supabase__apply_migration" - ] - } + ] } diff --git a/dev-dist/sw.js b/dev-dist/sw.js index bb5975d..da47ea6 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-ca84f546'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.ufhads5pjvs" + "revision": "0.tucc18p1f38" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/components/Community.tsx b/src/components/Community.tsx index 9155986..4a954a0 100644 --- a/src/components/Community.tsx +++ b/src/components/Community.tsx @@ -794,8 +794,10 @@ export default function Community() { ) : (
{(tradesSubTab === 'pending' ? pendingTrades : tradeHistory).map((trade) => { - const isSender = trade.sender_id === user?.id; - const otherUser = isSender ? trade.receiver : trade.sender; + const isUser1 = trade.user1_id === user?.id; + const myUserId = user?.id || ''; + const otherUserId = isUser1 ? trade.user2_id : trade.user1_id; + const otherUser = isUser1 ? trade.user2 : trade.user1; const statusColors: Record = { accepted: 'text-green-400', declined: 'text-red-400', @@ -819,7 +821,7 @@ export default function Community() {
- {isSender ? 'To' : 'From'}: {otherUser?.username} + With: {otherUser?.username}
@@ -829,18 +831,18 @@ export default function Community() { {/* Items */}
- {renderTradeItems(trade.items, trade.sender_id, isSender ? 'Give' : 'Receive')} - {renderTradeItems(trade.items, trade.receiver_id, isSender ? 'Get' : 'Send')} + {renderTradeItems(trade.items, myUserId, 'You Give')} + {renderTradeItems(trade.items, otherUserId, 'You Get')}
{canViewDetails && (

- {isSender ? 'Tap to view/edit' : 'Tap to view details'} + Tap to view details

)} - {/* Actions - Only show quick actions for sender (cancel) */} - {tradesSubTab === 'pending' && isSender && ( + {/* Actions - Allow any user to cancel pending trade */} + {tradesSubTab === 'pending' && (
e.stopPropagation()}>
@@ -246,7 +245,7 @@ export default function TradeDetail({

- {isSender ? 'You Give' : 'You Receive'} + You Give

@@ -285,7 +284,7 @@ export default function TradeDetail({

- {isSender ? 'You Receive' : 'They Give'} + You Receive

@@ -330,13 +329,13 @@ export default function TradeDetail({ )} {/* Price Difference */} - {!loading && (senderPrice > 0 || receiverPrice > 0) && ( + {!loading && (yourPrice > 0 || theirPrice > 0) && (
Value Difference: - 5 ? 'text-yellow-400' : 'text-gray-300'}> - ${Math.abs(senderPrice - receiverPrice).toFixed(2)} - {senderPrice > receiverPrice ? ' in sender favor' : senderPrice < receiverPrice ? ' in receiver favor' : ' (balanced)'} + 5 ? 'text-yellow-400' : 'text-gray-300'}> + ${Math.abs(yourPrice - theirPrice).toFixed(2)} + {yourPrice > theirPrice ? ' in your favor' : yourPrice < theirPrice ? ' in their favor' : ' (balanced)'}
@@ -422,9 +421,10 @@ export default function TradeDetail({ Waiting for {otherUser?.username} to respond...

) : ( - /* No editor yet (version 1) - original flow */ + /* No editor yet (initial trade) */ <> - {isSender ? ( + {isUser1 ? ( + /* User1 (initiator) can edit their initial offer */ ) : trade.editor_id === user?.id ? ( - /* User made the last edit - waiting for response */ -

- Waiting for {otherUser?.username} to respond... -

+ /* User made the last edit - can still edit while waiting for response */ + <> +

+ Waiting for {otherUser?.username} to respond... +

+ + ) : ( /* No editor yet (initial trade) */ <> -- 2.49.1 From d1728546b137ca51d86531668b926f297b0f3df4 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 26 Nov 2025 22:12:34 +0100 Subject: [PATCH 4/5] truc async --- dev-dist/sw.js | 2 +- src/components/Community.tsx | 181 ++++++++++++++++++++++++++++------- 2 files changed, 150 insertions(+), 33 deletions(-) diff --git a/dev-dist/sw.js b/dev-dist/sw.js index da47ea6..619a24c 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-ca84f546'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.tucc18p1f38" + "revision": "0.7vc86ovebpk" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/components/Community.tsx b/src/components/Community.tsx index 4a954a0..d8c38ea 100644 --- a/src/components/Community.tsx +++ b/src/components/Community.tsx @@ -102,6 +102,126 @@ export default function Community() { } }, [user]); + // ============ REALTIME SUBSCRIPTIONS ============ + // Subscribe to trade changes + useEffect(() => { + if (!user) return; + + const tradesChannel = supabase + .channel('trades-changes') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'trades', + }, + (payload: any) => { + // Filter for trades involving this user + const newData = payload.new || payload.old; + if (newData && (newData.user1_id === user.id || newData.user2_id === user.id)) { + console.log('Trade change:', payload); + loadTradesData(); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(tradesChannel); + }; + }, [user]); + + // Subscribe to friendship changes + useEffect(() => { + if (!user) return; + + const friendshipsChannel = supabase + .channel('friendships-changes') + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'friendships', + }, + (payload: any) => { + // Filter for friendships involving this user + const newData = payload.new || payload.old; + if (newData && (newData.requester_id === user.id || newData.addressee_id === user.id)) { + console.log('Friendship change:', payload); + loadFriendsData(); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(friendshipsChannel); + }; + }, [user]); + + // Subscribe to profile changes (for visibility updates) + useEffect(() => { + if (!user) return; + + const profilesChannel = supabase + .channel('profiles-changes') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'profiles', + }, + (payload: any) => { + console.log('Profile change:', payload); + // Reload public users if a profile's visibility changed + if (payload.new && payload.old && payload.new.collection_visibility !== payload.old.collection_visibility) { + loadPublicUsers(); + } + // Reload own profile if it's the current user + if (payload.new && payload.new.id === user.id) { + loadProfile(); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(profilesChannel); + }; + }, [user]); + + // Subscribe to collection changes when viewing someone's collection + useEffect(() => { + if (!user || !selectedUser) return; + + const collectionsChannel = supabase + .channel(`collections-${selectedUser.id}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'collections', + }, + (payload: any) => { + // Filter for the selected user's collections + const data = payload.new || payload.old; + if (data && data.user_id === selectedUser.id) { + console.log('Collection change for viewed user:', payload); + loadUserCollection(selectedUser.id); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(collectionsChannel); + }; + }, [user, selectedUser]); + const loadAllData = async () => { if (!user) return; setLoading(true); @@ -502,13 +622,13 @@ export default function Community() { // ============ MAIN VIEW ============ return ( -
- {/* Header */} -
-

Community

+
+
+ {/* Header */} +

Community

- {/* Tabs - Scrollable on mobile */} -
+ {/* Tabs */} +
{[ { id: 'browse' as Tab, label: 'Browse', icon: Globe }, { id: 'friends' as Tab, label: `Friends`, count: friends.length, icon: Users }, @@ -536,10 +656,7 @@ export default function Community() { ))}
-
- {/* Content */} -
{/* ============ BROWSE TAB ============ */} {activeTab === 'browse' && (
@@ -907,31 +1024,31 @@ export default function Community() {
)} -
- {/* Trade Detail Modal */} - {selectedTrade && ( - setSelectedTrade(null)} - onAccept={handleAcceptTrade} - onDecline={handleDeclineTrade} - onTradeUpdated={() => { - setSelectedTrade(null); - loadTradesData(); - }} + {/* Trade Detail Modal */} + {selectedTrade && ( + setSelectedTrade(null)} + onAccept={handleAcceptTrade} + onDecline={handleDeclineTrade} + onTradeUpdated={() => { + setSelectedTrade(null); + loadTradesData(); + }} + /> + )} + + {/* Confirm Modal */} + setConfirmModal({ ...confirmModal, isOpen: false })} + onConfirm={confirmModal.onConfirm} + title={confirmModal.title} + message={confirmModal.message} + variant={confirmModal.variant} /> - )} - - {/* Confirm Modal */} - setConfirmModal({ ...confirmModal, isOpen: false })} - onConfirm={confirmModal.onConfirm} - title={confirmModal.title} - message={confirmModal.message} - variant={confirmModal.variant} - /> +
); } -- 2.49.1 From 1183f0c7f6939f387619f2a9d993f420a1b0581a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 27 Nov 2025 11:27:04 +0100 Subject: [PATCH 5/5] add friend and request filtering in community view --- dev-dist/sw.js | 2 +- src/components/Community.tsx | 194 +++++++++---- ...250124000000_friends_trades_visibility.sql | 273 ++++++++++++++++++ .../20250126000000_trade_history.sql | 78 +++++ ....timestamp-1764157043179-658c718c76681.mjs | 104 +++++++ 5 files changed, 587 insertions(+), 64 deletions(-) create mode 100644 supabase/migrations/20250124000000_friends_trades_visibility.sql create mode 100644 supabase/migrations/20250126000000_trade_history.sql create mode 100644 vite.config.ts.timestamp-1764157043179-658c718c76681.mjs diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 619a24c..56d2826 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-ca84f546'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.7vc86ovebpk" + "revision": "0.vigoqq958cg" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/components/Community.tsx b/src/components/Community.tsx index d8c38ea..297f4d2 100644 --- a/src/components/Community.tsx +++ b/src/components/Community.tsx @@ -73,6 +73,8 @@ export default function Community() { const [friendSearch, setFriendSearch] = useState(''); const [friendSearchResults, setFriendSearchResults] = useState<{ id: string; username: string | null }[]>([]); const [searchingFriends, setSearchingFriends] = useState(false); + const [friendListFilter, setFriendListFilter] = useState(''); + const [requestsFilter, setRequestsFilter] = useState(''); // Trades state const [tradesSubTab, setTradesSubTab] = useState('pending'); @@ -755,80 +757,146 @@ export default function Community() { {/* Friends List */} {friendsSubTab === 'list' && ( - friends.length === 0 ? ( -

No friends yet

- ) : ( -
- {friends.map((friend) => ( -
- {friend.username || 'Unknown'} -
- - -
-
- ))} +
+ {/* Search input */} +
+ + setFriendListFilter(e.target.value)} + placeholder="Search friends..." + className="w-full pl-9 pr-8 py-2 bg-gray-700 border border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {friendListFilter && ( + + )}
- ) + + {friends.length === 0 ? ( +

No friends yet

+ ) : friends.filter((f) => + !friendListFilter || f.username?.toLowerCase().includes(friendListFilter.toLowerCase()) + ).length === 0 ? ( +

No friends match "{friendListFilter}"

+ ) : ( +
+ {friends + .filter((f) => !friendListFilter || f.username?.toLowerCase().includes(friendListFilter.toLowerCase())) + .map((friend) => ( +
+ {friend.username || 'Unknown'} +
+ + +
+
+ ))} +
+ )} +
)} {/* Requests */} {friendsSubTab === 'requests' && ( -
- {pendingRequests.length > 0 && ( -
-

Received

-
- {pendingRequests.map((req) => ( -
- {req.username || 'Unknown'} -
- - +
+ {/* Search input */} +
+ + setRequestsFilter(e.target.value)} + placeholder="Search requests..." + className="w-full pl-9 pr-8 py-2 bg-gray-700 border border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {requestsFilter && ( + + )} +
+ + {(() => { + const filteredPending = pendingRequests.filter((r) => + !requestsFilter || r.username?.toLowerCase().includes(requestsFilter.toLowerCase()) + ); + const filteredSent = sentRequests.filter((r) => + !requestsFilter || r.username?.toLowerCase().includes(requestsFilter.toLowerCase()) + ); + + return ( + <> + {filteredPending.length > 0 && ( +
+

Received

+
+ {filteredPending.map((req) => ( +
+ {req.username || 'Unknown'} +
+ + +
+
+ ))}
- ))} -
-
- )} + )} - {sentRequests.length > 0 && ( -
-

Sent

-
- {sentRequests.map((req) => ( -
-
- - {req.username || 'Unknown'} + {filteredSent.length > 0 && ( +
+

Sent

+
+ {filteredSent.map((req) => ( +
+
+ + {req.username || 'Unknown'} +
+ Pending +
+ ))}
- Pending
- ))} -
-
- )} + )} - {pendingRequests.length === 0 && sentRequests.length === 0 && ( -

No requests

- )} + {pendingRequests.length === 0 && sentRequests.length === 0 && ( +

No requests

+ )} + + {(pendingRequests.length > 0 || sentRequests.length > 0) && + filteredPending.length === 0 && filteredSent.length === 0 && ( +

No requests match "{requestsFilter}"

+ )} + + ); + })()}
)} diff --git a/supabase/migrations/20250124000000_friends_trades_visibility.sql b/supabase/migrations/20250124000000_friends_trades_visibility.sql new file mode 100644 index 0000000..c130929 --- /dev/null +++ b/supabase/migrations/20250124000000_friends_trades_visibility.sql @@ -0,0 +1,273 @@ +/* + # Friends, Trades, and Collection Visibility + + 1. Changes to profiles + - Add `collection_visibility` column (public, friends, private) + + 2. New Tables + - `friendships` - Friend relationships between users + - `trades` - Trade offers between users + - `trade_items` - Cards included in trades + + 3. Security + - RLS policies for all new tables + - Updated collection policies for visibility +*/ + +-- Add collection visibility to profiles +ALTER TABLE public.profiles +ADD COLUMN collection_visibility text DEFAULT 'private' +CHECK (collection_visibility IN ('public', 'friends', 'private')); + +-- ============================================= +-- FRIENDSHIPS TABLE +-- ============================================= +CREATE TABLE public.friendships ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + requester_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL, + addressee_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL, + status text DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')), + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE(requester_id, addressee_id), + CHECK (requester_id != addressee_id) +); + +ALTER TABLE public.friendships ENABLE ROW LEVEL SECURITY; + +-- Users can see friendships they're involved in +CREATE POLICY "Users can view their friendships" + ON public.friendships + FOR SELECT + TO authenticated + USING (requester_id = auth.uid() OR addressee_id = auth.uid()); + +-- Users can create friend requests +CREATE POLICY "Users can send friend requests" + ON public.friendships + FOR INSERT + TO authenticated + WITH CHECK (requester_id = auth.uid()); + +-- Users can update friendships they received (accept/decline) +CREATE POLICY "Users can respond to friend requests" + ON public.friendships + FOR UPDATE + TO authenticated + USING (addressee_id = auth.uid()); + +-- Users can delete their own friendships +CREATE POLICY "Users can delete their friendships" + ON public.friendships + FOR DELETE + TO authenticated + USING (requester_id = auth.uid() OR addressee_id = auth.uid()); + +-- ============================================= +-- TRADES TABLE +-- ============================================= +CREATE TABLE public.trades ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + sender_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL, + receiver_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL, + status text DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'cancelled')), + message text, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + CHECK (sender_id != receiver_id) +); + +ALTER TABLE public.trades ENABLE ROW LEVEL SECURITY; + +-- Users can see trades they're involved in +CREATE POLICY "Users can view their trades" + ON public.trades + FOR SELECT + TO authenticated + USING (sender_id = auth.uid() OR receiver_id = auth.uid()); + +-- Users can create trades +CREATE POLICY "Users can create trades" + ON public.trades + FOR INSERT + TO authenticated + WITH CHECK (sender_id = auth.uid()); + +-- Sender can cancel, receiver can accept/decline +CREATE POLICY "Users can update their trades" + ON public.trades + FOR UPDATE + TO authenticated + USING (sender_id = auth.uid() OR receiver_id = auth.uid()); + +-- ============================================= +-- TRADE ITEMS TABLE +-- ============================================= +CREATE TABLE public.trade_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + trade_id uuid REFERENCES public.trades(id) ON DELETE CASCADE NOT NULL, + owner_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL, + card_id text NOT NULL, + quantity integer DEFAULT 1 CHECK (quantity > 0), + created_at timestamptz DEFAULT now() +); + +ALTER TABLE public.trade_items ENABLE ROW LEVEL SECURITY; + +-- Users can see items in their trades +CREATE POLICY "Users can view trade items" + ON public.trade_items + FOR SELECT + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.trades + WHERE trades.id = trade_items.trade_id + AND (trades.sender_id = auth.uid() OR trades.receiver_id = auth.uid()) + ) + ); + +-- Users can add items to trades they created +CREATE POLICY "Users can add trade items" + ON public.trade_items + FOR INSERT + TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.trades + WHERE trades.id = trade_items.trade_id + AND trades.sender_id = auth.uid() + AND trades.status = 'pending' + ) + ); + +-- ============================================= +-- UPDATE COLLECTION POLICIES FOR VISIBILITY +-- ============================================= + +-- Drop old restrictive policy +DROP POLICY IF EXISTS "Users can view their own collection" ON public.collections; + +-- New policy: view own collection OR public collections OR friend's collections (if friends visibility) +CREATE POLICY "Users can view collections based on visibility" + ON public.collections + FOR SELECT + TO authenticated + USING ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.profiles + WHERE profiles.id = collections.user_id + AND profiles.collection_visibility = 'public' + ) + OR EXISTS ( + SELECT 1 FROM public.profiles p + JOIN public.friendships f ON ( + (f.requester_id = p.id AND f.addressee_id = auth.uid()) + OR (f.addressee_id = p.id AND f.requester_id = auth.uid()) + ) + WHERE p.id = collections.user_id + AND p.collection_visibility = 'friends' + AND f.status = 'accepted' + ) + ); + +-- ============================================= +-- UPDATE PROFILES POLICY FOR PUBLIC VIEWING +-- ============================================= + +-- Drop old restrictive policy +DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles; + +-- New policy: users can view all profiles (needed for friend search and public collections) +CREATE POLICY "Users can view profiles" + ON public.profiles + FOR SELECT + TO authenticated + USING (true); + +-- ============================================= +-- FUNCTION: Execute trade (transfer cards) +-- ============================================= +CREATE OR REPLACE FUNCTION public.execute_trade(trade_id uuid) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_trade RECORD; + v_item RECORD; +BEGIN + -- Get the trade + SELECT * INTO v_trade FROM public.trades WHERE id = trade_id; + + -- Check trade exists and is pending + IF v_trade IS NULL OR v_trade.status != 'pending' THEN + RETURN false; + END IF; + + -- Check caller is the receiver + IF v_trade.receiver_id != auth.uid() THEN + RETURN false; + END IF; + + -- Process each trade item + FOR v_item IN SELECT * FROM public.trade_items WHERE trade_items.trade_id = execute_trade.trade_id + LOOP + -- Determine new owner + DECLARE + v_new_owner uuid; + BEGIN + IF v_item.owner_id = v_trade.sender_id THEN + v_new_owner := v_trade.receiver_id; + ELSE + v_new_owner := v_trade.sender_id; + END IF; + + -- Remove from old owner's collection + UPDATE public.collections + SET quantity = quantity - v_item.quantity, + updated_at = now() + WHERE user_id = v_item.owner_id + AND card_id = v_item.card_id; + + -- Delete if quantity is 0 or less + DELETE FROM public.collections + WHERE user_id = v_item.owner_id + AND card_id = v_item.card_id + AND quantity <= 0; + + -- Add to new owner's collection + INSERT INTO public.collections (user_id, card_id, quantity) + VALUES (v_new_owner, v_item.card_id, v_item.quantity) + ON CONFLICT (user_id, card_id) + DO UPDATE SET + quantity = collections.quantity + v_item.quantity, + updated_at = now(); + END; + END LOOP; + + -- Mark trade as accepted + UPDATE public.trades + SET status = 'accepted', updated_at = now() + WHERE id = trade_id; + + RETURN true; +END; +$$; + +-- Add unique constraint on collections for upsert +ALTER TABLE public.collections +ADD CONSTRAINT collections_user_card_unique UNIQUE (user_id, card_id); + +-- ============================================= +-- INDEXES FOR PERFORMANCE +-- ============================================= +CREATE INDEX idx_friendships_requester ON public.friendships(requester_id); +CREATE INDEX idx_friendships_addressee ON public.friendships(addressee_id); +CREATE INDEX idx_friendships_status ON public.friendships(status); +CREATE INDEX idx_trades_sender ON public.trades(sender_id); +CREATE INDEX idx_trades_receiver ON public.trades(receiver_id); +CREATE INDEX idx_trades_status ON public.trades(status); +CREATE INDEX idx_trade_items_trade ON public.trade_items(trade_id); +CREATE INDEX idx_profiles_visibility ON public.profiles(collection_visibility); diff --git a/supabase/migrations/20250126000000_trade_history.sql b/supabase/migrations/20250126000000_trade_history.sql new file mode 100644 index 0000000..68bd94f --- /dev/null +++ b/supabase/migrations/20250126000000_trade_history.sql @@ -0,0 +1,78 @@ +-- Create trade_history table to track all versions of a trade +CREATE TABLE IF NOT EXISTS public.trade_history ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + trade_id uuid REFERENCES public.trades(id) ON DELETE CASCADE NOT NULL, + version integer NOT NULL, + editor_id uuid REFERENCES public.profiles(id) NOT NULL, + message text, + created_at timestamptz DEFAULT now(), + UNIQUE(trade_id, version) +); + +-- Create trade_history_items table to store cards for each version +CREATE TABLE IF NOT EXISTS public.trade_history_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + history_id uuid REFERENCES public.trade_history(id) ON DELETE CASCADE NOT NULL, + owner_id uuid REFERENCES public.profiles(id) NOT NULL, + card_id text NOT NULL, + quantity integer DEFAULT 1, + created_at timestamptz DEFAULT now() +); + +-- Add version column to trades table to track current version +ALTER TABLE public.trades ADD COLUMN IF NOT EXISTS version integer DEFAULT 1; + +-- Add editor_id to track who last edited the trade +ALTER TABLE public.trades ADD COLUMN IF NOT EXISTS editor_id uuid REFERENCES public.profiles(id); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_trade_history_trade_id ON public.trade_history(trade_id); +CREATE INDEX IF NOT EXISTS idx_trade_history_items_history_id ON public.trade_history_items(history_id); + +-- Enable RLS +ALTER TABLE public.trade_history ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.trade_history_items ENABLE ROW LEVEL SECURITY; + +-- RLS policies for trade_history +CREATE POLICY "Users can view history of their trades" + ON public.trade_history FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.trades + WHERE trades.id = trade_history.trade_id + AND (trades.sender_id = auth.uid() OR trades.receiver_id = auth.uid()) + ) + ); + +CREATE POLICY "Users can create history for their trades" + ON public.trade_history FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.trades + WHERE trades.id = trade_history.trade_id + AND (trades.sender_id = auth.uid() OR trades.receiver_id = auth.uid()) + ) + ); + +-- RLS policies for trade_history_items +CREATE POLICY "Users can view history items of their trades" + ON public.trade_history_items FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.trade_history th + JOIN public.trades t ON t.id = th.trade_id + WHERE th.id = trade_history_items.history_id + AND (t.sender_id = auth.uid() OR t.receiver_id = auth.uid()) + ) + ); + +CREATE POLICY "Users can create history items for their trades" + ON public.trade_history_items FOR INSERT + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.trade_history th + JOIN public.trades t ON t.id = th.trade_id + WHERE th.id = trade_history_items.history_id + AND (t.sender_id = auth.uid() OR t.receiver_id = auth.uid()) + ) + ); diff --git a/vite.config.ts.timestamp-1764157043179-658c718c76681.mjs b/vite.config.ts.timestamp-1764157043179-658c718c76681.mjs new file mode 100644 index 0000000..7897fed --- /dev/null +++ b/vite.config.ts.timestamp-1764157043179-658c718c76681.mjs @@ -0,0 +1,104 @@ +// vite.config.ts +import { defineConfig } from "file:///D:/Dev/deckerr/node_modules/vite/dist/node/index.js"; +import react from "file:///D:/Dev/deckerr/node_modules/@vitejs/plugin-react/dist/index.mjs"; +import { VitePWA } from "file:///D:/Dev/deckerr/node_modules/vite-plugin-pwa/dist/index.js"; +var vite_config_default = defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: "autoUpdate", + includeAssets: ["icon.svg"], + manifest: { + name: "Deckerr - Card Deck Manager", + short_name: "Deckerr", + description: "Manage your trading card game decks on the go", + theme_color: "#0f172a", + background_color: "#0f172a", + display: "standalone", + orientation: "portrait", + scope: "/", + start_url: "/", + icons: [ + { + src: "icon.svg", + sizes: "512x512", + type: "image/svg+xml", + purpose: "any" + }, + { + src: "icon.svg", + sizes: "512x512", + type: "image/svg+xml", + purpose: "maskable" + } + ], + categories: ["games", "utilities"], + shortcuts: [ + { + name: "My Decks", + short_name: "Decks", + description: "View your deck collection", + url: "/?page=home" + }, + { + name: "Search Cards", + short_name: "Search", + description: "Search for cards", + url: "/?page=search" + }, + { + name: "Life Counter", + short_name: "Life", + description: "Track life totals", + url: "/?page=life-counter" + } + ] + }, + workbox: { + globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2}"], + runtimeCaching: [ + { + urlPattern: /^https:\/\/api\.scryfall\.com\/.*/i, + handler: "CacheFirst", + options: { + cacheName: "scryfall-cache", + expiration: { + maxEntries: 500, + maxAgeSeconds: 60 * 60 * 24 * 7 + // 7 days + }, + cacheableResponse: { + statuses: [0, 200] + } + } + }, + { + urlPattern: /^https:\/\/cards\.scryfall\.io\/.*/i, + handler: "CacheFirst", + options: { + cacheName: "card-images-cache", + expiration: { + maxEntries: 1e3, + maxAgeSeconds: 60 * 60 * 24 * 30 + // 30 days + }, + cacheableResponse: { + statuses: [0, 200] + } + } + } + ] + }, + devOptions: { + enabled: true + } + }) + ], + optimizeDeps: { + exclude: ["lucide-react"] + } +}); +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJEOlxcXFxEZXZcXFxcZGVja2VyclwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiRDpcXFxcRGV2XFxcXGRlY2tlcnJcXFxcdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL0Q6L0Rldi9kZWNrZXJyL3ZpdGUuY29uZmlnLnRzXCI7aW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XHJcbmltcG9ydCByZWFjdCBmcm9tICdAdml0ZWpzL3BsdWdpbi1yZWFjdCc7XHJcbmltcG9ydCB7IFZpdGVQV0EgfSBmcm9tICd2aXRlLXBsdWdpbi1wd2EnO1xyXG5cclxuLy8gaHR0cHM6Ly92aXRlanMuZGV2L2NvbmZpZy9cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICBwbHVnaW5zOiBbXHJcbiAgICByZWFjdCgpLFxyXG4gICAgVml0ZVBXQSh7XHJcbiAgICAgIHJlZ2lzdGVyVHlwZTogJ2F1dG9VcGRhdGUnLFxyXG4gICAgICBpbmNsdWRlQXNzZXRzOiBbJ2ljb24uc3ZnJ10sXHJcbiAgICAgIG1hbmlmZXN0OiB7XHJcbiAgICAgICAgbmFtZTogJ0RlY2tlcnIgLSBDYXJkIERlY2sgTWFuYWdlcicsXHJcbiAgICAgICAgc2hvcnRfbmFtZTogJ0RlY2tlcnInLFxyXG4gICAgICAgIGRlc2NyaXB0aW9uOiAnTWFuYWdlIHlvdXIgdHJhZGluZyBjYXJkIGdhbWUgZGVja3Mgb24gdGhlIGdvJyxcclxuICAgICAgICB0aGVtZV9jb2xvcjogJyMwZjE3MmEnLFxyXG4gICAgICAgIGJhY2tncm91bmRfY29sb3I6ICcjMGYxNzJhJyxcclxuICAgICAgICBkaXNwbGF5OiAnc3RhbmRhbG9uZScsXHJcbiAgICAgICAgb3JpZW50YXRpb246ICdwb3J0cmFpdCcsXHJcbiAgICAgICAgc2NvcGU6ICcvJyxcclxuICAgICAgICBzdGFydF91cmw6ICcvJyxcclxuICAgICAgICBpY29uczogW1xyXG4gICAgICAgICAge1xyXG4gICAgICAgICAgICBzcmM6ICdpY29uLnN2ZycsXHJcbiAgICAgICAgICAgIHNpemVzOiAnNTEyeDUxMicsXHJcbiAgICAgICAgICAgIHR5cGU6ICdpbWFnZS9zdmcreG1sJyxcclxuICAgICAgICAgICAgcHVycG9zZTogJ2FueSdcclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIHNyYzogJ2ljb24uc3ZnJyxcclxuICAgICAgICAgICAgc2l6ZXM6ICc1MTJ4NTEyJyxcclxuICAgICAgICAgICAgdHlwZTogJ2ltYWdlL3N2Zyt4bWwnLFxyXG4gICAgICAgICAgICBwdXJwb3NlOiAnbWFza2FibGUnXHJcbiAgICAgICAgICB9XHJcbiAgICAgICAgXSxcclxuICAgICAgICBjYXRlZ29yaWVzOiBbJ2dhbWVzJywgJ3V0aWxpdGllcyddLFxyXG4gICAgICAgIHNob3J0Y3V0czogW1xyXG4gICAgICAgICAge1xyXG4gICAgICAgICAgICBuYW1lOiAnTXkgRGVja3MnLFxyXG4gICAgICAgICAgICBzaG9ydF9uYW1lOiAnRGVja3MnLFxyXG4gICAgICAgICAgICBkZXNjcmlwdGlvbjogJ1ZpZXcgeW91ciBkZWNrIGNvbGxlY3Rpb24nLFxyXG4gICAgICAgICAgICB1cmw6ICcvP3BhZ2U9aG9tZSdcclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIG5hbWU6ICdTZWFyY2ggQ2FyZHMnLFxyXG4gICAgICAgICAgICBzaG9ydF9uYW1lOiAnU2VhcmNoJyxcclxuICAgICAgICAgICAgZGVzY3JpcHRpb246ICdTZWFyY2ggZm9yIGNhcmRzJyxcclxuICAgICAgICAgICAgdXJsOiAnLz9wYWdlPXNlYXJjaCdcclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIG5hbWU6ICdMaWZlIENvdW50ZXInLFxyXG4gICAgICAgICAgICBzaG9ydF9uYW1lOiAnTGlmZScsXHJcbiAgICAgICAgICAgIGRlc2NyaXB0aW9uOiAnVHJhY2sgbGlmZSB0b3RhbHMnLFxyXG4gICAgICAgICAgICB1cmw6ICcvP3BhZ2U9bGlmZS1jb3VudGVyJ1xyXG4gICAgICAgICAgfVxyXG4gICAgICAgIF1cclxuICAgICAgfSxcclxuICAgICAgd29ya2JveDoge1xyXG4gICAgICAgIGdsb2JQYXR0ZXJuczogWycqKi8qLntqcyxjc3MsaHRtbCxpY28scG5nLHN2Zyx3b2ZmLHdvZmYyfSddLFxyXG4gICAgICAgIHJ1bnRpbWVDYWNoaW5nOiBbXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIHVybFBhdHRlcm46IC9eaHR0cHM6XFwvXFwvYXBpXFwuc2NyeWZhbGxcXC5jb21cXC8uKi9pLFxyXG4gICAgICAgICAgICBoYW5kbGVyOiAnQ2FjaGVGaXJzdCcsXHJcbiAgICAgICAgICAgIG9wdGlvbnM6IHtcclxuICAgICAgICAgICAgICBjYWNoZU5hbWU6ICdzY3J5ZmFsbC1jYWNoZScsXHJcbiAgICAgICAgICAgICAgZXhwaXJhdGlvbjoge1xyXG4gICAgICAgICAgICAgICAgbWF4RW50cmllczogNTAwLFxyXG4gICAgICAgICAgICAgICAgbWF4QWdlU2Vjb25kczogNjAgKiA2MCAqIDI0ICogNyAvLyA3IGRheXNcclxuICAgICAgICAgICAgICB9LFxyXG4gICAgICAgICAgICAgIGNhY2hlYWJsZVJlc3BvbnNlOiB7XHJcbiAgICAgICAgICAgICAgICBzdGF0dXNlczogWzAsIDIwMF1cclxuICAgICAgICAgICAgICB9XHJcbiAgICAgICAgICAgIH1cclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIHVybFBhdHRlcm46IC9eaHR0cHM6XFwvXFwvY2FyZHNcXC5zY3J5ZmFsbFxcLmlvXFwvLiovaSxcclxuICAgICAgICAgICAgaGFuZGxlcjogJ0NhY2hlRmlyc3QnLFxyXG4gICAgICAgICAgICBvcHRpb25zOiB7XHJcbiAgICAgICAgICAgICAgY2FjaGVOYW1lOiAnY2FyZC1pbWFnZXMtY2FjaGUnLFxyXG4gICAgICAgICAgICAgIGV4cGlyYXRpb246IHtcclxuICAgICAgICAgICAgICAgIG1heEVudHJpZXM6IDEwMDAsXHJcbiAgICAgICAgICAgICAgICBtYXhBZ2VTZWNvbmRzOiA2MCAqIDYwICogMjQgKiAzMCAvLyAzMCBkYXlzXHJcbiAgICAgICAgICAgICAgfSxcclxuICAgICAgICAgICAgICBjYWNoZWFibGVSZXNwb25zZToge1xyXG4gICAgICAgICAgICAgICAgc3RhdHVzZXM6IFswLCAyMDBdXHJcbiAgICAgICAgICAgICAgfVxyXG4gICAgICAgICAgICB9XHJcbiAgICAgICAgICB9XHJcbiAgICAgICAgXVxyXG4gICAgICB9LFxyXG4gICAgICBkZXZPcHRpb25zOiB7XHJcbiAgICAgICAgZW5hYmxlZDogdHJ1ZVxyXG4gICAgICB9XHJcbiAgICB9KVxyXG4gIF0sXHJcbiAgb3B0aW1pemVEZXBzOiB7XHJcbiAgICBleGNsdWRlOiBbJ2x1Y2lkZS1yZWFjdCddLFxyXG4gIH0sXHJcbn0pO1xyXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWtPLFNBQVMsb0JBQW9CO0FBQy9QLE9BQU8sV0FBVztBQUNsQixTQUFTLGVBQWU7QUFHeEIsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUztBQUFBLElBQ1AsTUFBTTtBQUFBLElBQ04sUUFBUTtBQUFBLE1BQ04sY0FBYztBQUFBLE1BQ2QsZUFBZSxDQUFDLFVBQVU7QUFBQSxNQUMxQixVQUFVO0FBQUEsUUFDUixNQUFNO0FBQUEsUUFDTixZQUFZO0FBQUEsUUFDWixhQUFhO0FBQUEsUUFDYixhQUFhO0FBQUEsUUFDYixrQkFBa0I7QUFBQSxRQUNsQixTQUFTO0FBQUEsUUFDVCxhQUFhO0FBQUEsUUFDYixPQUFPO0FBQUEsUUFDUCxXQUFXO0FBQUEsUUFDWCxPQUFPO0FBQUEsVUFDTDtBQUFBLFlBQ0UsS0FBSztBQUFBLFlBQ0wsT0FBTztBQUFBLFlBQ1AsTUFBTTtBQUFBLFlBQ04sU0FBUztBQUFBLFVBQ1g7QUFBQSxVQUNBO0FBQUEsWUFDRSxLQUFLO0FBQUEsWUFDTCxPQUFPO0FBQUEsWUFDUCxNQUFNO0FBQUEsWUFDTixTQUFTO0FBQUEsVUFDWDtBQUFBLFFBQ0Y7QUFBQSxRQUNBLFlBQVksQ0FBQyxTQUFTLFdBQVc7QUFBQSxRQUNqQyxXQUFXO0FBQUEsVUFDVDtBQUFBLFlBQ0UsTUFBTTtBQUFBLFlBQ04sWUFBWTtBQUFBLFlBQ1osYUFBYTtBQUFBLFlBQ2IsS0FBSztBQUFBLFVBQ1A7QUFBQSxVQUNBO0FBQUEsWUFDRSxNQUFNO0FBQUEsWUFDTixZQUFZO0FBQUEsWUFDWixhQUFhO0FBQUEsWUFDYixLQUFLO0FBQUEsVUFDUDtBQUFBLFVBQ0E7QUFBQSxZQUNFLE1BQU07QUFBQSxZQUNOLFlBQVk7QUFBQSxZQUNaLGFBQWE7QUFBQSxZQUNiLEtBQUs7QUFBQSxVQUNQO0FBQUEsUUFDRjtBQUFBLE1BQ0Y7QUFBQSxNQUNBLFNBQVM7QUFBQSxRQUNQLGNBQWMsQ0FBQywyQ0FBMkM7QUFBQSxRQUMxRCxnQkFBZ0I7QUFBQSxVQUNkO0FBQUEsWUFDRSxZQUFZO0FBQUEsWUFDWixTQUFTO0FBQUEsWUFDVCxTQUFTO0FBQUEsY0FDUCxXQUFXO0FBQUEsY0FDWCxZQUFZO0FBQUEsZ0JBQ1YsWUFBWTtBQUFBLGdCQUNaLGVBQWUsS0FBSyxLQUFLLEtBQUs7QUFBQTtBQUFBLGNBQ2hDO0FBQUEsY0FDQSxtQkFBbUI7QUFBQSxnQkFDakIsVUFBVSxDQUFDLEdBQUcsR0FBRztBQUFBLGNBQ25CO0FBQUEsWUFDRjtBQUFBLFVBQ0Y7QUFBQSxVQUNBO0FBQUEsWUFDRSxZQUFZO0FBQUEsWUFDWixTQUFTO0FBQUEsWUFDVCxTQUFTO0FBQUEsY0FDUCxXQUFXO0FBQUEsY0FDWCxZQUFZO0FBQUEsZ0JBQ1YsWUFBWTtBQUFBLGdCQUNaLGVBQWUsS0FBSyxLQUFLLEtBQUs7QUFBQTtBQUFBLGNBQ2hDO0FBQUEsY0FDQSxtQkFBbUI7QUFBQSxnQkFDakIsVUFBVSxDQUFDLEdBQUcsR0FBRztBQUFBLGNBQ25CO0FBQUEsWUFDRjtBQUFBLFVBQ0Y7QUFBQSxRQUNGO0FBQUEsTUFDRjtBQUFBLE1BQ0EsWUFBWTtBQUFBLFFBQ1YsU0FBUztBQUFBLE1BQ1g7QUFBQSxJQUNGLENBQUM7QUFBQSxFQUNIO0FBQUEsRUFDQSxjQUFjO0FBQUEsSUFDWixTQUFTLENBQUMsY0FBYztBQUFBLEVBQzFCO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K -- 2.49.1