mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
feat: Implement persistent sort order
This commit is contained in:
56
package-lock.json
generated
56
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
</Link>
|
||||
<Badge variant="secondary" className="text-xs font-semibold">
|
||||
#{position}
|
||||
</Badge>
|
||||
{getCategoryBadge(category)}
|
||||
</div>
|
||||
|
||||
@@ -240,7 +244,12 @@ export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCre
|
||||
>
|
||||
{rideName}
|
||||
</Link>
|
||||
{getCategoryBadge(category)}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Badge variant="secondary" className="text-xs font-semibold">
|
||||
#{position}
|
||||
</Badge>
|
||||
{getCategoryBadge(category)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -2,13 +2,27 @@ import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, LayoutGrid, List } from 'lucide-react';
|
||||
import { Plus, LayoutGrid, List, GripVertical } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
import { AddRideCreditDialog } from './AddRideCreditDialog';
|
||||
import { RideCreditCard } from './RideCreditCard';
|
||||
import { SortableRideCreditCard } from './SortableRideCreditCard';
|
||||
import { UserRideCredit } from '@/types/database';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
interface RideCreditsManagerProps {
|
||||
userId: string;
|
||||
@@ -18,9 +32,18 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
const [credits, setCredits] = useState<UserRideCredit[]>([]);
|
||||
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<string>('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) {
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Credit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isEditMode ? 'default' : 'outline'}
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 mr-2" />
|
||||
{isEditMode ? 'Done Editing' : 'Edit Order'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -211,6 +279,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">Custom Order</SelectItem>
|
||||
<SelectItem value="date">Most Recent</SelectItem>
|
||||
<SelectItem value="count">Most Ridden</SelectItem>
|
||||
<SelectItem value="name">Ride Name</SelectItem>
|
||||
@@ -252,15 +321,43 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isEditMode ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={credits.map(c => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{credits.map((credit, index) => (
|
||||
<SortableRideCreditCard
|
||||
key={credit.id}
|
||||
credit={credit}
|
||||
position={index + 1}
|
||||
viewMode={viewMode}
|
||||
onUpdate={handleCreditUpdated}
|
||||
onDelete={() => handleCreditDeleted(credit.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className={viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{credits.map((credit) => (
|
||||
{credits.map((credit, index) => (
|
||||
<RideCreditCard
|
||||
key={credit.id}
|
||||
credit={credit}
|
||||
position={index + 1}
|
||||
viewMode={viewMode}
|
||||
onUpdate={handleCreditUpdated}
|
||||
onDelete={() => handleCreditDeleted(credit.id)}
|
||||
|
||||
56
src/components/profile/SortableRideCreditCard.tsx
Normal file
56
src/components/profile/SortableRideCreditCard.tsx
Normal file
@@ -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 (
|
||||
<div ref={setNodeRef} style={style} className="relative">
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-2 left-2 z-10 cursor-grab active:cursor-grabbing p-2 rounded bg-background/80 backdrop-blur-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<RideCreditCard
|
||||
credit={credit}
|
||||
position={position}
|
||||
viewMode={viewMode}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PropertyKey, never>
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user