mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:31:12 -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: []
|
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: {
|
user_top_lists: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -2186,6 +2227,10 @@ export type Database = {
|
|||||||
Args: Record<PropertyKey, never>
|
Args: Record<PropertyKey, never>
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
migrate_user_list_items: {
|
||||||
|
Args: Record<PropertyKey, never>
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
update_company_ratings: {
|
update_company_ratings: {
|
||||||
Args: { target_company_id: string }
|
Args: { target_company_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
|
|||||||
@@ -200,4 +200,29 @@ export interface Review {
|
|||||||
moderation_status: 'pending' | 'approved' | 'rejected' | 'flagged';
|
moderation_status: 'pending' | 'approved' | 'rejected' | 'flagged';
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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