add friend and request filtering in community view
This commit is contained in:
1 parent
d1728546b1
commit
1183f0c7f6
5 files changed
+587
-64
No files matched your search
+1
-1
@@ -82,7 +82,7 @@ define(['./workbox-ca84f546'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.7vc86ovebpk"
|
"revision": "0.vigoqq958cg"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
+131
-63
@@ -73,6 +73,8 @@ export default function Community() {
|
|||||||
const [friendSearch, setFriendSearch] = useState('');
|
const [friendSearch, setFriendSearch] = useState('');
|
||||||
const [friendSearchResults, setFriendSearchResults] = useState<{ id: string; username: string | null }[]>([]);
|
const [friendSearchResults, setFriendSearchResults] = useState<{ id: string; username: string | null }[]>([]);
|
||||||
const [searchingFriends, setSearchingFriends] = useState(false);
|
const [searchingFriends, setSearchingFriends] = useState(false);
|
||||||
|
const [friendListFilter, setFriendListFilter] = useState('');
|
||||||
|
const [requestsFilter, setRequestsFilter] = useState('');
|
||||||
|
|
||||||
// Trades state
|
// Trades state
|
||||||
const [tradesSubTab, setTradesSubTab] = useState<TradesSubTab>('pending');
|
const [tradesSubTab, setTradesSubTab] = useState<TradesSubTab>('pending');
|
||||||
@@ -755,80 +757,146 @@ export default function Community() {
|
|||||||
|
|
||||||
{/* Friends List */}
|
{/* Friends List */}
|
||||||
{friendsSubTab === 'list' && (
|
{friendsSubTab === 'list' && (
|
||||||
friends.length === 0 ? (
|
<div className="space-y-3">
|
||||||
<p className="text-gray-400 text-center py-8 text-sm">No friends yet</p>
|
{/* Search input */}
|
||||||
) : (
|
<div className="relative">
|
||||||
<div className="space-y-2">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||||
{friends.map((friend) => (
|
<input
|
||||||
<div key={friend.id} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg">
|
type="text"
|
||||||
<span className="font-medium truncate">{friend.username || 'Unknown'}</span>
|
value={friendListFilter}
|
||||||
<div className="flex gap-1">
|
onChange={(e) => setFriendListFilter(e.target.value)}
|
||||||
<button
|
placeholder="Search friends..."
|
||||||
onClick={() => {
|
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"
|
||||||
setSelectedUser({ id: friend.id, username: friend.username, collection_visibility: 'friends' });
|
/>
|
||||||
loadUserCollection(friend.id);
|
{friendListFilter && (
|
||||||
}}
|
<button
|
||||||
className="p-2 text-blue-400 active:bg-blue-400/20 rounded-lg"
|
onClick={() => setFriendListFilter('')}
|
||||||
>
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||||
<Eye size={18} />
|
>
|
||||||
</button>
|
<X size={16} />
|
||||||
<button
|
</button>
|
||||||
onClick={() => handleRemoveFriend(friend.friendshipId, friend.username || 'user')}
|
)}
|
||||||
className="p-2 text-red-400 active:bg-red-400/20 rounded-lg"
|
|
||||||
>
|
|
||||||
<UserMinus size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-center py-8 text-sm">No friends yet</p>
|
||||||
|
) : friends.filter((f) =>
|
||||||
|
!friendListFilter || f.username?.toLowerCase().includes(friendListFilter.toLowerCase())
|
||||||
|
).length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-center py-8 text-sm">No friends match "{friendListFilter}"</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{friends
|
||||||
|
.filter((f) => !friendListFilter || f.username?.toLowerCase().includes(friendListFilter.toLowerCase()))
|
||||||
|
.map((friend) => (
|
||||||
|
<div key={friend.id} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg">
|
||||||
|
<span className="font-medium truncate">{friend.username || 'Unknown'}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser({ id: friend.id, username: friend.username, collection_visibility: 'friends' });
|
||||||
|
loadUserCollection(friend.id);
|
||||||
|
}}
|
||||||
|
className="p-2 text-blue-400 active:bg-blue-400/20 rounded-lg"
|
||||||
|
>
|
||||||
|
<Eye size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveFriend(friend.friendshipId, friend.username || 'user')}
|
||||||
|
className="p-2 text-red-400 active:bg-red-400/20 rounded-lg"
|
||||||
|
>
|
||||||
|
<UserMinus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Requests */}
|
{/* Requests */}
|
||||||
{friendsSubTab === 'requests' && (
|
{friendsSubTab === 'requests' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{pendingRequests.length > 0 && (
|
{/* Search input */}
|
||||||
<div>
|
<div className="relative">
|
||||||
<p className="text-xs text-gray-500 mb-2">Received</p>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||||
<div className="space-y-2">
|
<input
|
||||||
{pendingRequests.map((req) => (
|
type="text"
|
||||||
<div key={req.id} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg">
|
value={requestsFilter}
|
||||||
<span className="font-medium truncate">{req.username || 'Unknown'}</span>
|
onChange={(e) => setRequestsFilter(e.target.value)}
|
||||||
<div className="flex gap-1">
|
placeholder="Search requests..."
|
||||||
<button onClick={() => handleAcceptRequest(req.friendshipId)} className="p-2 text-green-400 active:bg-green-400/20 rounded-lg">
|
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"
|
||||||
<Check size={18} />
|
/>
|
||||||
</button>
|
{requestsFilter && (
|
||||||
<button onClick={() => handleDeclineRequest(req.friendshipId)} className="p-2 text-red-400 active:bg-red-400/20 rounded-lg">
|
<button
|
||||||
<X size={18} />
|
onClick={() => setRequestsFilter('')}
|
||||||
</button>
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
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 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Received</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredPending.map((req) => (
|
||||||
|
<div key={req.id} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg">
|
||||||
|
<span className="font-medium truncate">{req.username || 'Unknown'}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => handleAcceptRequest(req.friendshipId)} className="p-2 text-green-400 active:bg-green-400/20 rounded-lg">
|
||||||
|
<Check size={18} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeclineRequest(req.friendshipId)} className="p-2 text-red-400 active:bg-red-400/20 rounded-lg">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sentRequests.length > 0 && (
|
{filteredSent.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-2">Sent</p>
|
<p className="text-xs text-gray-500 mb-2">Sent</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sentRequests.map((req) => (
|
{filteredSent.map((req) => (
|
||||||
<div key={req.id} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg">
|
<div key={req.id} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Send size={14} className="text-gray-500" />
|
<Send size={14} className="text-gray-500" />
|
||||||
<span className="font-medium truncate">{req.username || 'Unknown'}</span>
|
<span className="font-medium truncate">{req.username || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-yellow-500">Pending</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-yellow-500">Pending</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pendingRequests.length === 0 && sentRequests.length === 0 && (
|
{pendingRequests.length === 0 && sentRequests.length === 0 && (
|
||||||
<p className="text-gray-400 text-center py-8 text-sm">No requests</p>
|
<p className="text-gray-400 text-center py-8 text-sm">No requests</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(pendingRequests.length > 0 || sentRequests.length > 0) &&
|
||||||
|
filteredPending.length === 0 && filteredSent.length === 0 && (
|
||||||
|
<p className="text-gray-400 text-center py-8 text-sm">No requests match "{requestsFilter}"</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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())
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJEOlxcXFxEZXZcXFxcZGVja2VyclwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiRDpcXFxcRGV2XFxcXGRlY2tlcnJcXFxcdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL0Q6L0Rldi9kZWNrZXJyL3ZpdGUuY29uZmlnLnRzXCI7aW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XHJcbmltcG9ydCByZWFjdCBmcm9tICdAdml0ZWpzL3BsdWdpbi1yZWFjdCc7XHJcbmltcG9ydCB7IFZpdGVQV0EgfSBmcm9tICd2aXRlLXBsdWdpbi1wd2EnO1xyXG5cclxuLy8gaHR0cHM6Ly92aXRlanMuZGV2L2NvbmZpZy9cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICBwbHVnaW5zOiBbXHJcbiAgICByZWFjdCgpLFxyXG4gICAgVml0ZVBXQSh7XHJcbiAgICAgIHJlZ2lzdGVyVHlwZTogJ2F1dG9VcGRhdGUnLFxyXG4gICAgICBpbmNsdWRlQXNzZXRzOiBbJ2ljb24uc3ZnJ10sXHJcbiAgICAgIG1hbmlmZXN0OiB7XHJcbiAgICAgICAgbmFtZTogJ0RlY2tlcnIgLSBDYXJkIERlY2sgTWFuYWdlcicsXHJcbiAgICAgICAgc2hvcnRfbmFtZTogJ0RlY2tlcnInLFxyXG4gICAgICAgIGRlc2NyaXB0aW9uOiAnTWFuYWdlIHlvdXIgdHJhZGluZyBjYXJkIGdhbWUgZGVja3Mgb24gdGhlIGdvJyxcclxuICAgICAgICB0aGVtZV9jb2xvcjogJyMwZjE3MmEnLFxyXG4gICAgICAgIGJhY2tncm91bmRfY29sb3I6ICcjMGYxNzJhJyxcclxuICAgICAgICBkaXNwbGF5OiAnc3RhbmRhbG9uZScsXHJcbiAgICAgICAgb3JpZW50YXRpb246ICdwb3J0cmFpdCcsXHJcbiAgICAgICAgc2NvcGU6ICcvJyxcclxuICAgICAgICBzdGFydF91cmw6ICcvJyxcclxuICAgICAgICBpY29uczogW1xyXG4gICAgICAgICAge1xyXG4gICAgICAgICAgICBzcmM6ICdpY29uLnN2ZycsXHJcbiAgICAgICAgICAgIHNpemVzOiAnNTEyeDUxMicsXHJcbiAgICAgICAgICAgIHR5cGU6ICdpbWFnZS9zdmcreG1sJyxcclxuICAgICAgICAgICAgcHVycG9zZTogJ2FueSdcclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIHNyYzogJ2ljb24uc3ZnJyxcclxuICAgICAgICAgICAgc2l6ZXM6ICc1MTJ4NTEyJyxcclxuICAgICAgICAgICAgdHlwZTogJ2ltYWdlL3N2Zyt4bWwnLFxyXG4gICAgICAgICAgICBwdXJwb3NlOiAnbWFza2FibGUnXHJcbiAgICAgICAgICB9XHJcbiAgICAgICAgXSxcclxuICAgICAgICBjYXRlZ29yaWVzOiBbJ2dhbWVzJywgJ3V0aWxpdGllcyddLFxyXG4gICAgICAgIHNob3J0Y3V0czogW1xyXG4gICAgICAgICAge1xyXG4gICAgICAgICAgICBuYW1lOiAnTXkgRGVja3MnLFxyXG4gICAgICAgICAgICBzaG9ydF9uYW1lOiAnRGVja3MnLFxyXG4gICAgICAgICAgICBkZXNjcmlwdGlvbjogJ1ZpZXcgeW91ciBkZWNrIGNvbGxlY3Rpb24nLFxyXG4gICAgICAgICAgICB1cmw6ICcvP3BhZ2U9aG9tZSdcclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIG5hbWU6ICdTZWFyY2ggQ2FyZHMnLFxyXG4gICAgICAgICAgICBzaG9ydF9uYW1lOiAnU2VhcmNoJyxcclxuICAgICAgICAgICAgZGVzY3JpcHRpb246ICdTZWFyY2ggZm9yIGNhcmRzJyxcclxuICAgICAgICAgICAgdXJsOiAnLz9wYWdlPXNlYXJjaCdcclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIG5hbWU6ICdMaWZlIENvdW50ZXInLFxyXG4gICAgICAgICAgICBzaG9ydF9uYW1lOiAnTGlmZScsXHJcbiAgICAgICAgICAgIGRlc2NyaXB0aW9uOiAnVHJhY2sgbGlmZSB0b3RhbHMnLFxyXG4gICAgICAgICAgICB1cmw6ICcvP3BhZ2U9bGlmZS1jb3VudGVyJ1xyXG4gICAgICAgICAgfVxyXG4gICAgICAgIF1cclxuICAgICAgfSxcclxuICAgICAgd29ya2JveDoge1xyXG4gICAgICAgIGdsb2JQYXR0ZXJuczogWycqKi8qLntqcyxjc3MsaHRtbCxpY28scG5nLHN2Zyx3b2ZmLHdvZmYyfSddLFxyXG4gICAgICAgIHJ1bnRpbWVDYWNoaW5nOiBbXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIHVybFBhdHRlcm46IC9eaHR0cHM6XFwvXFwvYXBpXFwuc2NyeWZhbGxcXC5jb21cXC8uKi9pLFxyXG4gICAgICAgICAgICBoYW5kbGVyOiAnQ2FjaGVGaXJzdCcsXHJcbiAgICAgICAgICAgIG9wdGlvbnM6IHtcclxuICAgICAgICAgICAgICBjYWNoZU5hbWU6ICdzY3J5ZmFsbC1jYWNoZScsXHJcbiAgICAgICAgICAgICAgZXhwaXJhdGlvbjoge1xyXG4gICAgICAgICAgICAgICAgbWF4RW50cmllczogNTAwLFxyXG4gICAgICAgICAgICAgICAgbWF4QWdlU2Vjb25kczogNjAgKiA2MCAqIDI0ICogNyAvLyA3IGRheXNcclxuICAgICAgICAgICAgICB9LFxyXG4gICAgICAgICAgICAgIGNhY2hlYWJsZVJlc3BvbnNlOiB7XHJcbiAgICAgICAgICAgICAgICBzdGF0dXNlczogWzAsIDIwMF1cclxuICAgICAgICAgICAgICB9XHJcbiAgICAgICAgICAgIH1cclxuICAgICAgICAgIH0sXHJcbiAgICAgICAgICB7XHJcbiAgICAgICAgICAgIHVybFBhdHRlcm46IC9eaHR0cHM6XFwvXFwvY2FyZHNcXC5zY3J5ZmFsbFxcLmlvXFwvLiovaSxcclxuICAgICAgICAgICAgaGFuZGxlcjogJ0NhY2hlRmlyc3QnLFxyXG4gICAgICAgICAgICBvcHRpb25zOiB7XHJcbiAgICAgICAgICAgICAgY2FjaGVOYW1lOiAnY2FyZC1pbWFnZXMtY2FjaGUnLFxyXG4gICAgICAgICAgICAgIGV4cGlyYXRpb246IHtcclxuICAgICAgICAgICAgICAgIG1heEVudHJpZXM6IDEwMDAsXHJcbiAgICAgICAgICAgICAgICBtYXhBZ2VTZWNvbmRzOiA2MCAqIDYwICogMjQgKiAzMCAvLyAzMCBkYXlzXHJcbiAgICAgICAgICAgICAgfSxcclxuICAgICAgICAgICAgICBjYWNoZWFibGVSZXNwb25zZToge1xyXG4gICAgICAgICAgICAgICAgc3RhdHVzZXM6IFswLCAyMDBdXHJcbiAgICAgICAgICAgICAgfVxyXG4gICAgICAgICAgICB9XHJcbiAgICAgICAgICB9XHJcbiAgICAgICAgXVxyXG4gICAgICB9LFxyXG4gICAgICBkZXZPcHRpb25zOiB7XHJcbiAgICAgICAgZW5hYmxlZDogdHJ1ZVxyXG4gICAgICB9XHJcbiAgICB9KVxyXG4gIF0sXHJcbiAgb3B0aW1pemVEZXBzOiB7XHJcbiAgICBleGNsdWRlOiBbJ2x1Y2lkZS1yZWFjdCddLFxyXG4gIH0sXHJcbn0pO1xyXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWtPLFNBQVMsb0JBQW9CO0FBQy9QLE9BQU8sV0FBVztBQUNsQixTQUFTLGVBQWU7QUFHeEIsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUztBQUFBLElBQ1AsTUFBTTtBQUFBLElBQ04sUUFBUTtBQUFBLE1BQ04sY0FBYztBQUFBLE1BQ2QsZUFBZSxDQUFDLFVBQVU7QUFBQSxNQUMxQixVQUFVO0FBQUEsUUFDUixNQUFNO0FBQUEsUUFDTixZQUFZO0FBQUEsUUFDWixhQUFhO0FBQUEsUUFDYixhQUFhO0FBQUEsUUFDYixrQkFBa0I7QUFBQSxRQUNsQixTQUFTO0FBQUEsUUFDVCxhQUFhO0FBQUEsUUFDYixPQUFPO0FBQUEsUUFDUCxXQUFXO0FBQUEsUUFDWCxPQUFPO0FBQUEsVUFDTDtBQUFBLFlBQ0UsS0FBSztBQUFBLFlBQ0wsT0FBTztBQUFBLFlBQ1AsTUFBTTtBQUFBLFlBQ04sU0FBUztBQUFBLFVBQ
Line truncated
|
||||||
Reference in new issue
Block a user