mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
Refactor user lists
This commit is contained in:
154
src/components/lists/ListDisplay.tsx
Normal file
154
src/components/lists/ListDisplay.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { UserTopList, UserTopListItem, Park, Ride, Company } from "@/types/database";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ListDisplayProps {
|
||||
list: UserTopList;
|
||||
}
|
||||
|
||||
interface EnrichedListItem extends UserTopListItem {
|
||||
entity?: Park | Ride | Company;
|
||||
}
|
||||
|
||||
export function ListDisplay({ list }: ListDisplayProps) {
|
||||
const [items, setItems] = useState<EnrichedListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItemsWithEntities();
|
||||
}, [list.id]);
|
||||
|
||||
const fetchItemsWithEntities = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// First, get the list items
|
||||
const { data: itemsData, error: itemsError } = await supabase
|
||||
.from("user_top_list_items")
|
||||
.select("*")
|
||||
.eq("list_id", list.id)
|
||||
.order("position", { ascending: true });
|
||||
|
||||
if (itemsError) {
|
||||
console.error("Error fetching items:", itemsError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then, fetch the entities for each item
|
||||
const enrichedItems = await Promise.all(
|
||||
(itemsData as UserTopListItem[]).map(async (item) => {
|
||||
let entity = null;
|
||||
|
||||
if (item.entity_type === "park") {
|
||||
const { data } = await supabase
|
||||
.from("parks")
|
||||
.select("id, name, slug, park_type, location_id")
|
||||
.eq("id", item.entity_id)
|
||||
.single();
|
||||
entity = data;
|
||||
} else if (item.entity_type === "ride") {
|
||||
const { data } = await supabase
|
||||
.from("rides")
|
||||
.select("id, name, slug, category, park_id")
|
||||
.eq("id", item.entity_id)
|
||||
.single();
|
||||
entity = data;
|
||||
} else if (item.entity_type === "company") {
|
||||
const { data } = await supabase
|
||||
.from("companies")
|
||||
.select("id, name, slug, company_type")
|
||||
.eq("id", item.entity_id)
|
||||
.single();
|
||||
entity = data;
|
||||
}
|
||||
|
||||
return { ...item, entity };
|
||||
})
|
||||
);
|
||||
|
||||
setItems(enrichedItems);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getEntityUrl = (item: EnrichedListItem) => {
|
||||
if (!item.entity) return "#";
|
||||
|
||||
const entity = item.entity as any;
|
||||
|
||||
if (item.entity_type === "park") {
|
||||
return `/parks/${entity.slug}`;
|
||||
} else if (item.entity_type === "ride") {
|
||||
// We need park slug for rides
|
||||
return `/rides/${entity.slug}`;
|
||||
} else if (item.entity_type === "company") {
|
||||
return `/companies/${entity.slug}`;
|
||||
}
|
||||
|
||||
return "#";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-4 text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
This list is empty. Click "Edit" to add items.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.id} className="flex items-start gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<span className="font-bold text-lg text-muted-foreground min-w-[2rem]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
{item.entity ? (
|
||||
<Link
|
||||
to={getEntityUrl(item)}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{(item.entity as any).name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium text-muted-foreground">
|
||||
[Deleted or unavailable]
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{item.entity_type}
|
||||
</Badge>
|
||||
{item.entity && item.entity_type === "park" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{(item.entity as Park).park_type}
|
||||
</Badge>
|
||||
)}
|
||||
{item.entity && item.entity_type === "ride" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{(item.entity as Ride).category}
|
||||
</Badge>
|
||||
)}
|
||||
{item.entity && item.entity_type === "company" && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{(item.entity as Company).company_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{item.notes && (
|
||||
<p className="text-sm text-muted-foreground mt-2 italic">
|
||||
"{item.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
221
src/components/lists/ListItemEditor.tsx
Normal file
221
src/components/lists/ListItemEditor.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { UserTopList, UserTopListItem } from "@/types/database";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { GripVertical, Trash2, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ListSearch } from "./ListSearch";
|
||||
|
||||
interface ListItemEditorProps {
|
||||
list: UserTopList;
|
||||
onUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ListItemEditor({ list, onUpdate, onClose }: ListItemEditorProps) {
|
||||
const [items, setItems] = useState<UserTopListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [list.id]);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("user_top_list_items")
|
||||
.select("*")
|
||||
.eq("list_id", list.id)
|
||||
.order("position", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to load list items");
|
||||
console.error(error);
|
||||
} else {
|
||||
setItems(data as UserTopListItem[]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAddItem = async (entityType: string, entityId: string, entityName: string) => {
|
||||
const newPosition = items.length + 1;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("user_top_list_items")
|
||||
.insert({
|
||||
list_id: list.id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
position: newPosition,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.code === "23505") {
|
||||
toast.error("This item is already in your list");
|
||||
} else {
|
||||
toast.error("Failed to add item");
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
toast.success(`Added ${entityName} to list`);
|
||||
fetchItems();
|
||||
onUpdate();
|
||||
setShowSearch(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = async (itemId: string) => {
|
||||
const { error } = await supabase
|
||||
.from("user_top_list_items")
|
||||
.delete()
|
||||
.eq("id", itemId);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to remove item");
|
||||
console.error(error);
|
||||
} else {
|
||||
toast.success("Item removed");
|
||||
// Reorder remaining items
|
||||
const remainingItems = items.filter(i => i.id !== itemId);
|
||||
await reorderItems(remainingItems);
|
||||
fetchItems();
|
||||
onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNotes = async (itemId: string, notes: string) => {
|
||||
const { error } = await supabase
|
||||
.from("user_top_list_items")
|
||||
.update({ notes })
|
||||
.eq("id", itemId);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to update notes");
|
||||
console.error(error);
|
||||
} else {
|
||||
setItems(items.map(i => i.id === itemId ? { ...i, notes } : i));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveItem = async (itemId: string, direction: "up" | "down") => {
|
||||
const currentIndex = items.findIndex(i => i.id === itemId);
|
||||
if (
|
||||
(direction === "up" && currentIndex === 0) ||
|
||||
(direction === "down" && currentIndex === items.length - 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...items];
|
||||
const swapIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
[newItems[currentIndex], newItems[swapIndex]] = [newItems[swapIndex], newItems[currentIndex]];
|
||||
|
||||
await reorderItems(newItems);
|
||||
setItems(newItems);
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
const reorderItems = async (orderedItems: UserTopListItem[]) => {
|
||||
const updates = orderedItems.map((item, index) => ({
|
||||
id: item.id,
|
||||
position: index + 1,
|
||||
}));
|
||||
|
||||
for (const update of updates) {
|
||||
await supabase
|
||||
.from("user_top_list_items")
|
||||
.update({ position: update.position })
|
||||
.eq("id", update.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-4">Loading items...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold">Edit List Items</h3>
|
||||
<div className="space-x-2">
|
||||
<Button size="sm" onClick={() => setShowSearch(!showSearch)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Item
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<ListSearch
|
||||
listType={list.list_type}
|
||||
onSelect={handleAddItem}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No items in this list yet. Click "Add Item" to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-2 p-3 border rounded-lg bg-card"
|
||||
>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleMoveItem(item.id, "up")}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium">{item.entity_type} - {item.entity_id}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`notes-${item.id}`} className="text-xs">
|
||||
Notes (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`notes-${item.id}`}
|
||||
value={item.notes || ""}
|
||||
onChange={(e) => handleUpdateNotes(item.id, e.target.value)}
|
||||
placeholder="Add personal notes about this item..."
|
||||
className="h-16 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
src/components/lists/ListSearch.tsx
Normal file
177
src/components/lists/ListSearch.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Plus, X } from "lucide-react";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ListSearchProps {
|
||||
listType: string;
|
||||
onSelect: (entityType: string, entityId: string, entityName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "park" | "ride" | "company";
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function ListSearch({ listType, onSelect, onClose }: ListSearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length >= 2) {
|
||||
searchEntities();
|
||||
} else {
|
||||
setResults([]);
|
||||
}
|
||||
}, [debouncedQuery, listType]);
|
||||
|
||||
const searchEntities = async () => {
|
||||
setLoading(true);
|
||||
const searchResults: SearchResult[] = [];
|
||||
|
||||
// Determine which entity types to search based on list type
|
||||
const shouldSearchParks = listType === "parks" || listType === "mixed";
|
||||
const shouldSearchRides = listType === "rides" || listType === "coasters" || listType === "mixed";
|
||||
const shouldSearchCompanies = listType === "companies" || listType === "mixed";
|
||||
|
||||
// Search parks
|
||||
if (shouldSearchParks) {
|
||||
const { data: parks } = await supabase
|
||||
.from("parks")
|
||||
.select("id, name, park_type, location_id")
|
||||
.ilike("name", `%${debouncedQuery}%`)
|
||||
.limit(10);
|
||||
|
||||
if (parks) {
|
||||
searchResults.push(
|
||||
...parks.map((park) => ({
|
||||
id: park.id,
|
||||
name: park.name,
|
||||
type: "park" as const,
|
||||
subtitle: park.park_type,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Search rides
|
||||
if (shouldSearchRides) {
|
||||
const { data: rides } = await supabase
|
||||
.from("rides")
|
||||
.select("id, name, category, park:parks(name)")
|
||||
.ilike("name", `%${debouncedQuery}%`)
|
||||
.limit(10);
|
||||
|
||||
if (rides) {
|
||||
searchResults.push(
|
||||
...rides.map((ride: any) => ({
|
||||
id: ride.id,
|
||||
name: ride.name,
|
||||
type: "ride" as const,
|
||||
subtitle: ride.park?.name || ride.category,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Search companies
|
||||
if (shouldSearchCompanies) {
|
||||
const { data: companies } = await supabase
|
||||
.from("companies")
|
||||
.select("id, name, company_type")
|
||||
.ilike("name", `%${debouncedQuery}%`)
|
||||
.limit(10);
|
||||
|
||||
if (companies) {
|
||||
searchResults.push(
|
||||
...companies.map((company) => ({
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
type: "company" as const,
|
||||
subtitle: company.company_type,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setResults(searchResults);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-card space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search for parks, rides, or companies..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length === 0 && debouncedQuery.length >= 2 && (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No results found. Try a different search term.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<div
|
||||
key={`${result.type}-${result.id}`}
|
||||
className="flex items-center justify-between p-2 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{result.name}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{result.type}
|
||||
</Badge>
|
||||
{result.subtitle && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{result.subtitle}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onSelect(result.type, result.id, result.name)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debouncedQuery.length < 2 && (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
Type at least 2 characters to search
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
src/components/lists/UserListManager.tsx
Normal file
310
src/components/lists/UserListManager.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { UserTopList, UserTopListItem } from "@/types/database";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, Trash2, Edit, Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ListItemEditor } from "./ListItemEditor";
|
||||
import { ListDisplay } from "./ListDisplay";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function UserListManager() {
|
||||
const { user } = useAuth();
|
||||
const [lists, setLists] = useState<UserTopList[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingList, setEditingList] = useState<UserTopList | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const [newListTitle, setNewListTitle] = useState("");
|
||||
const [newListDescription, setNewListDescription] = useState("");
|
||||
const [newListType, setNewListType] = useState<string>("mixed");
|
||||
const [newListIsPublic, setNewListIsPublic] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchLists();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchLists = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from("user_top_lists")
|
||||
.select(`
|
||||
*,
|
||||
user_top_list_items (
|
||||
id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
position,
|
||||
notes,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
`)
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to load lists");
|
||||
console.error(error);
|
||||
} else {
|
||||
// Map Supabase data to UserTopList interface
|
||||
const mappedLists: UserTopList[] = (data || []).map((list: any) => ({
|
||||
id: list.id,
|
||||
user_id: list.user_id,
|
||||
title: list.title,
|
||||
description: list.description,
|
||||
list_type: list.list_type as 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed',
|
||||
is_public: list.is_public,
|
||||
created_at: list.created_at,
|
||||
updated_at: list.updated_at,
|
||||
items: list.user_top_list_items || [],
|
||||
}));
|
||||
setLists(mappedLists);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateList = async () => {
|
||||
if (!user || !newListTitle.trim()) {
|
||||
toast.error("Please enter a list title");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("user_top_lists")
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
title: newListTitle,
|
||||
description: newListDescription || null,
|
||||
list_type: newListType,
|
||||
is_public: newListIsPublic,
|
||||
items: [], // Legacy field, will be empty
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to create list");
|
||||
console.error(error);
|
||||
} else {
|
||||
toast.success("List created successfully");
|
||||
const newList: UserTopList = {
|
||||
id: data.id,
|
||||
user_id: data.user_id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
list_type: data.list_type as 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed',
|
||||
is_public: data.is_public,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
items: [],
|
||||
};
|
||||
setLists([newList, ...lists]);
|
||||
setIsCreateDialogOpen(false);
|
||||
setNewListTitle("");
|
||||
setNewListDescription("");
|
||||
setNewListType("mixed");
|
||||
setNewListIsPublic(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteList = async (listId: string) => {
|
||||
const { error } = await supabase
|
||||
.from("user_top_lists")
|
||||
.delete()
|
||||
.eq("id", listId);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to delete list");
|
||||
console.error(error);
|
||||
} else {
|
||||
toast.success("List deleted");
|
||||
setLists(lists.filter(l => l.id !== listId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (list: UserTopList) => {
|
||||
const { error } = await supabase
|
||||
.from("user_top_lists")
|
||||
.update({ is_public: !list.is_public })
|
||||
.eq("id", list.id);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to update list");
|
||||
console.error(error);
|
||||
} else {
|
||||
toast.success(`List is now ${!list.is_public ? "public" : "private"}`);
|
||||
setLists(lists.map(l =>
|
||||
l.id === list.id ? { ...l, is_public: !l.is_public } : l
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading lists...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">My Lists</h2>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create List
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New List</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new list to organize your favorite parks, rides, or companies.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="My Top 10 Coasters"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={newListDescription}
|
||||
onChange={(e) => setNewListDescription(e.target.value)}
|
||||
placeholder="A list of my all-time favorite roller coasters"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">List Type</Label>
|
||||
<Select value={newListType} onValueChange={setNewListType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="parks">Parks</SelectItem>
|
||||
<SelectItem value="rides">Rides</SelectItem>
|
||||
<SelectItem value="coasters">Coasters</SelectItem>
|
||||
<SelectItem value="companies">Companies</SelectItem>
|
||||
<SelectItem value="mixed">Mixed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="public"
|
||||
checked={newListIsPublic}
|
||||
onCheckedChange={setNewListIsPublic}
|
||||
/>
|
||||
<Label htmlFor="public">Make this list public</Label>
|
||||
</div>
|
||||
<Button onClick={handleCreateList} className="w-full">
|
||||
Create List
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{lists.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-muted-foreground mb-4">You haven't created any lists yet.</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First List
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{lists.map((list) => (
|
||||
<Card key={list.id}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>{list.title}</CardTitle>
|
||||
{list.description && (
|
||||
<CardDescription>{list.description}</CardDescription>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className="text-xs bg-secondary px-2 py-1 rounded">
|
||||
{list.list_type}
|
||||
</span>
|
||||
<span className="text-xs bg-secondary px-2 py-1 rounded">
|
||||
{list.items?.length || 0} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleVisibility(list)}
|
||||
title={list.is_public ? "Make private" : "Make public"}
|
||||
>
|
||||
{list.is_public ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingList(list)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteList(list.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{editingList?.id === list.id ? (
|
||||
<ListItemEditor
|
||||
list={list}
|
||||
onUpdate={fetchLists}
|
||||
onClose={() => setEditingList(null)}
|
||||
/>
|
||||
) : (
|
||||
<ListDisplay list={list} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2069,6 +2069,47 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
user_top_list_items: {
|
||||
Row: {
|
||||
created_at: string
|
||||
entity_id: string
|
||||
entity_type: string
|
||||
id: string
|
||||
list_id: string
|
||||
notes: string | null
|
||||
position: number
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
entity_id: string
|
||||
entity_type: string
|
||||
id?: string
|
||||
list_id: string
|
||||
notes?: string | null
|
||||
position: number
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
entity_id?: string
|
||||
entity_type?: string
|
||||
id?: string
|
||||
list_id?: string
|
||||
notes?: string | null
|
||||
position?: number
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "user_top_list_items_list_id_fkey"
|
||||
columns: ["list_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_top_lists"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
user_top_lists: {
|
||||
Row: {
|
||||
created_at: string
|
||||
@@ -2186,6 +2227,10 @@ export type Database = {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: undefined
|
||||
}
|
||||
migrate_user_list_items: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: undefined
|
||||
}
|
||||
update_company_ratings: {
|
||||
Args: { target_company_id: string }
|
||||
Returns: undefined
|
||||
|
||||
@@ -200,4 +200,29 @@ export interface Review {
|
||||
moderation_status: 'pending' | 'approved' | 'rejected' | 'flagged';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserTopList {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
list_type: 'parks' | 'rides' | 'coasters' | 'companies' | 'mixed';
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items?: UserTopListItem[]; // New relational data
|
||||
}
|
||||
|
||||
export interface UserTopListItem {
|
||||
id: string;
|
||||
list_id: string;
|
||||
entity_type: 'park' | 'ride' | 'company';
|
||||
entity_id: string;
|
||||
position: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Populated via joins
|
||||
entity?: Park | Ride | Company;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Phase 4: User Lists Refactor - Create relational list items table
|
||||
|
||||
-- Create user_top_list_items table with proper relational structure
|
||||
CREATE TABLE IF NOT EXISTS public.user_top_list_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
list_id uuid NOT NULL REFERENCES public.user_top_lists(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('park', 'ride', 'company')),
|
||||
entity_id uuid NOT NULL,
|
||||
position integer NOT NULL,
|
||||
notes text,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT unique_list_position UNIQUE(list_id, position),
|
||||
CONSTRAINT unique_list_entity UNIQUE(list_id, entity_type, entity_id)
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE public.user_top_list_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policy: Users can manage items in their own lists
|
||||
CREATE POLICY "Users can manage their own list items"
|
||||
ON public.user_top_list_items
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.user_top_lists
|
||||
WHERE id = user_top_list_items.list_id
|
||||
AND user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS Policy: Public list items can be viewed by anyone
|
||||
CREATE POLICY "Public list items can be viewed"
|
||||
ON public.user_top_list_items
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.user_top_lists
|
||||
WHERE id = user_top_list_items.list_id
|
||||
AND is_public = true
|
||||
)
|
||||
);
|
||||
|
||||
-- Create data migration function to move JSON data to relational structure
|
||||
CREATE OR REPLACE FUNCTION public.migrate_user_list_items()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
list_record RECORD;
|
||||
item_record JSONB;
|
||||
item_position INTEGER;
|
||||
BEGIN
|
||||
-- Iterate through all lists with items
|
||||
FOR list_record IN
|
||||
SELECT id, items
|
||||
FROM public.user_top_lists
|
||||
WHERE items IS NOT NULL AND jsonb_array_length(items) > 0
|
||||
LOOP
|
||||
item_position := 1;
|
||||
|
||||
-- Iterate through each item in the JSON array
|
||||
FOR item_record IN
|
||||
SELECT value FROM jsonb_array_elements(list_record.items)
|
||||
LOOP
|
||||
-- Insert into relational table, skip if already exists
|
||||
INSERT INTO public.user_top_list_items (
|
||||
list_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
position,
|
||||
notes
|
||||
) VALUES (
|
||||
list_record.id,
|
||||
item_record->>'entity_type',
|
||||
(item_record->>'entity_id')::uuid,
|
||||
item_position,
|
||||
item_record->>'notes'
|
||||
)
|
||||
ON CONFLICT (list_id, entity_type, entity_id) DO NOTHING;
|
||||
|
||||
item_position := item_position + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Migration completed successfully';
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add performance indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_user_list_items_list_position
|
||||
ON public.user_top_list_items (list_id, position);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_list_items_entity
|
||||
ON public.user_top_list_items (entity_type, entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_list_items_list_type
|
||||
ON public.user_top_list_items (list_id, entity_type);
|
||||
|
||||
-- Add trigger for updated_at
|
||||
CREATE TRIGGER update_user_top_list_items_updated_at
|
||||
BEFORE UPDATE ON public.user_top_list_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- Run migration to move existing data
|
||||
SELECT public.migrate_user_list_items();
|
||||
Reference in New Issue
Block a user