feat: Implement persistent sort order

This commit is contained in:
gpt-engineer-app[bot]
2025-10-16 15:18:45 +00:00
parent 3283e47b25
commit 7b0faf9bb2
7 changed files with 368 additions and 7 deletions

56
package-lock.json generated
View File

@@ -8,6 +8,9 @@
"name": "vite_react_shadcn_ts", "name": "vite_react_shadcn_ts",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@marsidev/react-turnstile": "^1.3.1", "@marsidev/react-turnstile": "^1.3.1",
"@novu/react": "^3.10.1", "@novu/react": "^3.10.1",
@@ -173,6 +176,59 @@
"solid-js": "^1.8" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.10", "version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",

View File

@@ -11,6 +11,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@marsidev/react-turnstile": "^1.3.1", "@marsidev/react-turnstile": "^1.3.1",
"@novu/react": "^3.10.1", "@novu/react": "^3.10.1",

View File

@@ -23,12 +23,13 @@ import {
interface RideCreditCardProps { interface RideCreditCardProps {
credit: UserRideCredit; credit: UserRideCredit;
position: number;
viewMode: 'grid' | 'list'; viewMode: 'grid' | 'list';
onUpdate: () => void; onUpdate: () => void;
onDelete: () => 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 [isEditing, setIsEditing] = useState(false);
const [editCount, setEditCount] = useState(credit.ride_count); const [editCount, setEditCount] = useState(credit.ride_count);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -116,6 +117,9 @@ export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCre
> >
{rideName} {rideName}
</Link> </Link>
<Badge variant="secondary" className="text-xs font-semibold">
#{position}
</Badge>
{getCategoryBadge(category)} {getCategoryBadge(category)}
</div> </div>
@@ -240,8 +244,13 @@ export function RideCreditCard({ credit, viewMode, onUpdate, onDelete }: RideCre
> >
{rideName} {rideName}
</Link> </Link>
<div className="flex gap-1 flex-shrink-0">
<Badge variant="secondary" className="text-xs font-semibold">
#{position}
</Badge>
{getCategoryBadge(category)} {getCategoryBadge(category)}
</div> </div>
</div>
<Link <Link
to={`/parks/${parkSlug}`} to={`/parks/${parkSlug}`}

View File

@@ -2,13 +2,27 @@ import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getErrorMessage } from '@/lib/errorHandler'; import { getErrorMessage } from '@/lib/errorHandler';
import { AddRideCreditDialog } from './AddRideCreditDialog'; import { AddRideCreditDialog } from './AddRideCreditDialog';
import { RideCreditCard } from './RideCreditCard'; import { RideCreditCard } from './RideCreditCard';
import { SortableRideCreditCard } from './SortableRideCreditCard';
import { UserRideCredit } from '@/types/database'; 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 { interface RideCreditsManagerProps {
userId: string; userId: string;
@@ -18,9 +32,18 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
const [credits, setCredits] = useState<UserRideCredit[]>([]); const [credits, setCredits] = useState<UserRideCredit[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); 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 [filterCategory, setFilterCategory] = useState<string>('all');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
useEffect(() => { useEffect(() => {
fetchCredits(); fetchCredits();
@@ -72,7 +95,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
return; return;
} }
// Apply sorting // Apply sorting - default to sort_order when available
if (sortBy === 'date') { if (sortBy === 'date') {
query = query.order('first_ride_date', { ascending: false, nullsFirst: false }); query = query.order('first_ride_date', { ascending: false, nullsFirst: false });
} else if (sortBy === 'count') { } else if (sortBy === 'count') {
@@ -80,12 +103,15 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
} else if (sortBy === 'name') { } else if (sortBy === 'name') {
// Note: Can't order by joined table - we'll sort client-side // Note: Can't order by joined table - we'll sort client-side
query = query.order('created_at', { ascending: false }); 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; const { data, error } = await query;
if (error) throw error; if (error) throw error;
let processedData = (data || []) as UserRideCredit[]; let processedData = (data || []) as any[];
// Sort by name client-side if needed // Sort by name client-side if needed
if (sortBy === 'name') { 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 = { const stats = {
totalRides: credits.reduce((sum, c) => sum + c.ride_count, 0), totalRides: credits.reduce((sum, c) => sum + c.ride_count, 0),
uniqueCredits: credits.length, uniqueCredits: credits.length,
@@ -190,6 +250,14 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Add Credit Add Credit
</Button> </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>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -211,6 +279,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
<SelectValue placeholder="Sort by" /> <SelectValue placeholder="Sort by" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="custom">Custom Order</SelectItem>
<SelectItem value="date">Most Recent</SelectItem> <SelectItem value="date">Most Recent</SelectItem>
<SelectItem value="count">Most Ridden</SelectItem> <SelectItem value="count">Most Ridden</SelectItem>
<SelectItem value="name">Ride Name</SelectItem> <SelectItem value="name">Ride Name</SelectItem>
@@ -252,15 +321,43 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </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' <div className={viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4' ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
: 'space-y-4' : 'space-y-4'
}> }>
{credits.map((credit) => ( {credits.map((credit, index) => (
<RideCreditCard <RideCreditCard
key={credit.id} key={credit.id}
credit={credit} credit={credit}
position={index + 1}
viewMode={viewMode} viewMode={viewMode}
onUpdate={handleCreditUpdated} onUpdate={handleCreditUpdated}
onDelete={() => handleCreditDeleted(credit.id)} onDelete={() => handleCreditDeleted(credit.id)}

View 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>
);
}

View File

@@ -3184,6 +3184,7 @@ export type Database = {
id: string id: string
ride_count: number | null ride_count: number | null
ride_id: string ride_id: string
sort_order: number | null
updated_at: string updated_at: string
user_id: string user_id: string
} }
@@ -3193,6 +3194,7 @@ export type Database = {
id?: string id?: string
ride_count?: number | null ride_count?: number | null
ride_id: string ride_id: string
sort_order?: number | null
updated_at?: string updated_at?: string
user_id: string user_id: string
} }
@@ -3202,6 +3204,7 @@ export type Database = {
id?: string id?: string
ride_count?: number | null ride_count?: number | null
ride_id?: string ride_id?: string
sort_order?: number | null
updated_at?: string updated_at?: string
user_id?: string user_id?: string
} }
@@ -3421,6 +3424,10 @@ export type Database = {
Args: { target_user_id: string } Args: { target_user_id: string }
Returns: undefined Returns: undefined
} }
backfill_sort_orders: {
Args: Record<PropertyKey, never>
Returns: undefined
}
can_approve_submission_item: { can_approve_submission_item: {
Args: { item_id: string } Args: { item_id: string }
Returns: boolean Returns: boolean
@@ -3602,6 +3609,10 @@ export type Database = {
Args: { moderator_id: string; submission_id: string } Args: { moderator_id: string; submission_id: string }
Returns: boolean Returns: boolean
} }
reorder_ride_credit: {
Args: { p_credit_id: string; p_new_position: number }
Returns: undefined
}
revoke_my_session: { revoke_my_session: {
Args: { session_id: string } Args: { session_id: string }
Returns: undefined Returns: undefined

View File

@@ -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;