From 7b0faf9bb2c498ad0b9a63ce4675df7eaea5be51 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:18:45 +0000 Subject: [PATCH] feat: Implement persistent sort order --- package-lock.json | 56 ++++++++ package.json | 3 + src/components/profile/RideCreditCard.tsx | 13 +- src/components/profile/RideCreditsManager.tsx | 107 ++++++++++++++- .../profile/SortableRideCreditCard.tsx | 56 ++++++++ src/integrations/supabase/types.ts | 11 ++ ...0_bcac97b6-eda1-429d-ab9a-0e6a6aa344d4.sql | 129 ++++++++++++++++++ 7 files changed, 368 insertions(+), 7 deletions(-) create mode 100644 src/components/profile/SortableRideCreditCard.tsx create mode 100644 supabase/migrations/20251016151450_bcac97b6-eda1-429d-ab9a-0e6a6aa344d4.sql diff --git a/package-lock.json b/package-lock.json index 41f82087..e003029e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "vite_react_shadcn_ts", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.10.0", "@marsidev/react-turnstile": "^1.3.1", "@novu/react": "^3.10.1", @@ -173,6 +176,59 @@ "solid-js": "^1.8" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", diff --git a/package.json b/package.json index 50d7d9df..340b6b5f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.10.0", "@marsidev/react-turnstile": "^1.3.1", "@novu/react": "^3.10.1", diff --git a/src/components/profile/RideCreditCard.tsx b/src/components/profile/RideCreditCard.tsx index f8d69543..f283f9f9 100644 --- a/src/components/profile/RideCreditCard.tsx +++ b/src/components/profile/RideCreditCard.tsx @@ -23,12 +23,13 @@ import { interface RideCreditCardProps { credit: UserRideCredit; + position: number; viewMode: 'grid' | 'list'; onUpdate: () => void; onDelete: () => void; } -export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCreditCardProps) { +export function RideCreditCard({ credit, position, viewMode, onUpdate, onDelete }: RideCreditCardProps) { const [isEditing, setIsEditing] = useState(false); const [editCount, setEditCount] = useState(credit.ride_count); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -116,6 +117,9 @@ export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCre > {rideName} + + #{position} + {getCategoryBadge(category)} @@ -240,7 +244,12 @@ export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCre > {rideName} - {getCategoryBadge(category)} +
+ + #{position} + + {getCategoryBadge(category)} +
([]); const [loading, setLoading] = useState(true); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); - const [sortBy, setSortBy] = useState<'date' | 'count' | 'name'>('date'); + const [sortBy, setSortBy] = useState<'date' | 'count' | 'name' | 'custom'>('custom'); const [filterCategory, setFilterCategory] = useState('all'); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); useEffect(() => { fetchCredits(); @@ -72,7 +95,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { return; } - // Apply sorting + // Apply sorting - default to sort_order when available if (sortBy === 'date') { query = query.order('first_ride_date', { ascending: false, nullsFirst: false }); } else if (sortBy === 'count') { @@ -80,12 +103,15 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { } else if (sortBy === 'name') { // Note: Can't order by joined table - we'll sort client-side query = query.order('created_at', { ascending: false }); + } else { + // Default to custom sort order + query = query.order('sort_order', { ascending: true, nullsFirst: false }); } const { data, error } = await query; if (error) throw error; - let processedData = (data || []) as UserRideCredit[]; + let processedData = (data || []) as any[]; // Sort by name client-side if needed if (sortBy === 'name') { @@ -132,6 +158,40 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { } }; + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + const oldIndex = credits.findIndex(c => c.id === String(active.id)); + const newIndex = credits.findIndex(c => c.id === String(over.id)); + + if (oldIndex === -1 || newIndex === -1) return; + + // Optimistic update + const newCredits = arrayMove(credits, oldIndex, newIndex); + setCredits(newCredits); + + try { + // Call RPC to persist the change + const { error } = await supabase.rpc('reorder_ride_credit', { + p_credit_id: String(active.id), + p_new_position: newIndex + 1 + }); + + if (error) throw error; + + // Refetch to get accurate sort_order values + fetchCredits(); + toast.success('Order updated'); + } catch (error) { + console.error('Error reordering credit:', error); + toast.error(getErrorMessage(error)); + // Revert on error + fetchCredits(); + } + }; + const stats = { totalRides: credits.reduce((sum, c) => sum + c.ride_count, 0), uniqueCredits: credits.length, @@ -190,6 +250,14 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { Add Credit + +
@@ -211,6 +279,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) { + Custom Order Most Recent Most Ridden Ride Name @@ -252,15 +321,43 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
+ ) : isEditMode ? ( + + c.id)} + strategy={verticalListSortingStrategy} + > +
+ {credits.map((credit, index) => ( + handleCreditDeleted(credit.id)} + /> + ))} +
+
+
) : (
- {credits.map((credit) => ( + {credits.map((credit, index) => ( handleCreditDeleted(credit.id)} diff --git a/src/components/profile/SortableRideCreditCard.tsx b/src/components/profile/SortableRideCreditCard.tsx new file mode 100644 index 00000000..9f500757 --- /dev/null +++ b/src/components/profile/SortableRideCreditCard.tsx @@ -0,0 +1,56 @@ +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical } from 'lucide-react'; +import { RideCreditCard } from './RideCreditCard'; +import { UserRideCredit } from '@/types/database'; + +interface SortableRideCreditCardProps { + credit: UserRideCredit; + position: number; + viewMode: 'grid' | 'list'; + onUpdate: () => void; + onDelete: () => void; +} + +export function SortableRideCreditCard({ + credit, + position, + viewMode, + onUpdate, + onDelete, +}: SortableRideCreditCardProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: credit.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+ +
+ + +
+ ); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index dc824810..7b46b55c 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -3184,6 +3184,7 @@ export type Database = { id: string ride_count: number | null ride_id: string + sort_order: number | null updated_at: string user_id: string } @@ -3193,6 +3194,7 @@ export type Database = { id?: string ride_count?: number | null ride_id: string + sort_order?: number | null updated_at?: string user_id: string } @@ -3202,6 +3204,7 @@ export type Database = { id?: string ride_count?: number | null ride_id?: string + sort_order?: number | null updated_at?: string user_id?: string } @@ -3421,6 +3424,10 @@ export type Database = { Args: { target_user_id: string } Returns: undefined } + backfill_sort_orders: { + Args: Record + Returns: undefined + } can_approve_submission_item: { Args: { item_id: string } Returns: boolean @@ -3602,6 +3609,10 @@ export type Database = { Args: { moderator_id: string; submission_id: string } Returns: boolean } + reorder_ride_credit: { + Args: { p_credit_id: string; p_new_position: number } + Returns: undefined + } revoke_my_session: { Args: { session_id: string } Returns: undefined diff --git a/supabase/migrations/20251016151450_bcac97b6-eda1-429d-ab9a-0e6a6aa344d4.sql b/supabase/migrations/20251016151450_bcac97b6-eda1-429d-ab9a-0e6a6aa344d4.sql new file mode 100644 index 00000000..821d445d --- /dev/null +++ b/supabase/migrations/20251016151450_bcac97b6-eda1-429d-ab9a-0e6a6aa344d4.sql @@ -0,0 +1,129 @@ +-- Add sort_order column to user_ride_credits +ALTER TABLE user_ride_credits + ADD COLUMN sort_order INTEGER; + +-- Create unique index to prevent duplicate positions per user +CREATE UNIQUE INDEX idx_user_ride_credits_sort_order + ON user_ride_credits(user_id, sort_order) + WHERE sort_order IS NOT NULL; + +-- Function to reorder a ride credit to a new position +CREATE OR REPLACE FUNCTION reorder_ride_credit( + p_credit_id UUID, + p_new_position INTEGER +) RETURNS VOID AS $$ +DECLARE + v_user_id UUID; + v_old_position INTEGER; + v_max_position INTEGER; +BEGIN + -- Get credit details + SELECT user_id, sort_order INTO v_user_id, v_old_position + FROM user_ride_credits + WHERE id = p_credit_id; + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Credit not found'; + END IF; + + -- Lock user's credits to prevent race conditions + PERFORM 1 FROM user_ride_credits WHERE user_id = v_user_id FOR UPDATE; + + -- Get max position for validation + SELECT COALESCE(MAX(sort_order), 0) INTO v_max_position + FROM user_ride_credits + WHERE user_id = v_user_id; + + -- Validate new position + IF p_new_position < 1 OR p_new_position > v_max_position THEN + RAISE EXCEPTION 'Invalid position. Must be between 1 and %', v_max_position; + END IF; + + -- If position unchanged, do nothing + IF v_old_position = p_new_position THEN + RETURN; + END IF; + + -- Temporarily set to NULL to avoid unique constraint violation + UPDATE user_ride_credits SET sort_order = NULL WHERE id = p_credit_id; + + IF p_new_position < v_old_position THEN + -- Moving up: shift down items between new and old position + UPDATE user_ride_credits + SET sort_order = sort_order + 1 + WHERE user_id = v_user_id + AND sort_order >= p_new_position + AND sort_order < v_old_position; + ELSE + -- Moving down: shift up items between old and new position + UPDATE user_ride_credits + SET sort_order = sort_order - 1 + WHERE user_id = v_user_id + AND sort_order > v_old_position + AND sort_order <= p_new_position; + END IF; + + -- Set new position + UPDATE user_ride_credits SET sort_order = p_new_position WHERE id = p_credit_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Function to backfill sort_order for existing credits +CREATE OR REPLACE FUNCTION backfill_sort_orders() +RETURNS void AS $$ +DECLARE + user_record RECORD; + credit_record RECORD; + position_counter INTEGER; +BEGIN + FOR user_record IN SELECT DISTINCT user_id FROM user_ride_credits LOOP + position_counter := 0; + + FOR credit_record IN + SELECT id + FROM user_ride_credits + WHERE user_id = user_record.user_id + ORDER BY first_ride_date ASC NULLS LAST, created_at ASC + LOOP + position_counter := position_counter + 1; + UPDATE user_ride_credits + SET sort_order = position_counter + WHERE id = credit_record.id; + END LOOP; + END LOOP; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Run backfill to populate existing data +SELECT backfill_sort_orders(); + +-- Update auto-review trigger to assign position at end +CREATE OR REPLACE FUNCTION auto_add_ride_credit_on_review() +RETURNS trigger AS $$ +DECLARE + v_next_position INTEGER; +BEGIN + IF NEW.ride_id IS NOT NULL THEN + IF NOT EXISTS ( + SELECT 1 FROM public.user_ride_credits + WHERE user_id = NEW.user_id AND ride_id = NEW.ride_id + ) THEN + -- Get next position + SELECT COALESCE(MAX(sort_order), 0) + 1 INTO v_next_position + FROM public.user_ride_credits + WHERE user_id = NEW.user_id; + + -- Insert with position at end + INSERT INTO public.user_ride_credits ( + user_id, ride_id, first_ride_date, ride_count, sort_order + ) VALUES ( + NEW.user_id, NEW.ride_id, NEW.visit_date, 1, v_next_position + ); + + RAISE NOTICE 'Auto-added ride credit for user % on ride %', NEW.user_id, NEW.ride_id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; \ No newline at end of file