diff --git a/src/components/resources/FavoritesSidebar.tsx b/src/components/resources/FavoritesSidebar.tsx index e1abdb3..c8ec2b2 100644 --- a/src/components/resources/FavoritesSidebar.tsx +++ b/src/components/resources/FavoritesSidebar.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo } from 'react'; -import { IconFolder, IconFolderOpen, IconPlus, IconSearch, IconDotsVertical, IconEdit, IconTrash, IconDownload } from '@tabler/icons-react'; +import { IconFolder, IconFolderOpen, IconPlus, IconSearch, IconDotsVertical, IconEdit, IconTrash, IconDownload, IconAlertTriangle } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -13,6 +13,7 @@ import { import { useFavoriteFolders, FavoriteFolder } from '@/hooks/useFavoriteFolders'; import { cn } from '@/lib/utils'; import { useDroppable } from '@dnd-kit/core'; +import { UserFavorite, MAX_FOLDER_ITEMS } from '@/hooks/useUserFavorites'; interface FavoritesSidebarProps { selectedFolderId: string | null; @@ -23,6 +24,7 @@ interface FavoritesSidebarProps { onDownloadFolder: (folder: FavoriteFolder) => void; searchQuery: string; onSearchChange: (query: string) => void; + favoritesData: UserFavorite[]; } const FolderItem = ({ @@ -33,7 +35,8 @@ const FolderItem = ({ onEdit, onDelete, onDownload, - getChildren + getChildren, + folderItemCount }: { folder: FavoriteFolder; level?: number; @@ -43,6 +46,7 @@ const FolderItem = ({ onDelete: (id: string) => void; onDownload: (folder: FavoriteFolder) => void; getChildren: (parentId: string) => FavoriteFolder[]; + folderItemCount: number; }) => { const [isOpen, setIsOpen] = useState(false); @@ -57,6 +61,7 @@ const FolderItem = ({ const childrenFolders = getChildren(folder.id); const hasChildren = childrenFolders.length > 0; const isSelected = selectedFolderId === folder.id; + const isFull = folderItemCount >= MAX_FOLDER_ITEMS; return (
@@ -89,6 +94,15 @@ const FolderItem = ({ {folder.name} + + {folderItemCount}/{MAX_FOLDER_ITEMS} + + {isFull && ( + + )}
@@ -128,6 +142,7 @@ const FolderItem = ({ onDelete={onDelete} onDownload={onDownload} getChildren={getChildren} + folderItemCount={0} /> ))} @@ -144,7 +159,8 @@ const FavoritesSidebar = ({ onDeleteFolder, onDownloadFolder, searchQuery, - onSearchChange + onSearchChange, + favoritesData }: FavoritesSidebarProps) => { const { folders, isLoading } = useFavoriteFolders(); @@ -163,6 +179,10 @@ const FavoritesSidebar = ({ return folders.filter(f => f.parent_id === parentId); }; + const getFolderItemCount = (folderId: string) => { + return favoritesData.filter(f => f.folder_id === folderId).length; + }; + const filteredFolders = useMemo(() => { if (!searchQuery) return rootFolders; const query = searchQuery.toLowerCase(); @@ -228,6 +248,7 @@ const FavoritesSidebar = ({ onDelete={onDeleteFolder} onDownload={onDownloadFolder} getChildren={getChildren} + folderItemCount={getFolderItemCount(folder.id)} /> )) ) : ( @@ -241,6 +262,7 @@ const FavoritesSidebar = ({ onDelete={onDeleteFolder} onDownload={onDownloadFolder} getChildren={getChildren} + folderItemCount={getFolderItemCount(folder.id)} /> )) )} diff --git a/src/components/resources/FavoritesTab.tsx b/src/components/resources/FavoritesTab.tsx index eae4d7e..84d2a24 100644 --- a/src/components/resources/FavoritesTab.tsx +++ b/src/components/resources/FavoritesTab.tsx @@ -10,6 +10,7 @@ import { IconHeart } from '@tabler/icons-react'; import FavoritesSidebar from './FavoritesSidebar'; import { useFavoriteFolders, FavoriteFolder } from '@/hooks/useFavoriteFolders'; import FolderDialog from './FolderDialog'; +import { MAX_FOLDER_ITEMS } from '@/hooks/useUserFavorites'; import { toast } from 'sonner'; import JSZip from 'jszip'; import { saveAs } from 'file-saver'; @@ -125,6 +126,13 @@ const FavoritesTab = ({ onSelectResource }: FavoritesTabProps) => { const resourceUrl = getResourceUrl(resource); const folderId = over.data.current.folder.id; + // Block drag into full folders + const folderCount = favoritesData.filter(f => f.folder_id === folderId).length; + if (folderCount >= MAX_FOLDER_ITEMS) { + toast.error(`This folder already has ${MAX_FOLDER_ITEMS} items. Please create another folder.`); + return; + } + if (resourceUrl) { moveFavorite(resourceUrl, folderId); } @@ -262,6 +270,7 @@ const FavoritesTab = ({ onSelectResource }: FavoritesTabProps) => { onDownloadFolder={handleDownloadFolder} searchQuery={searchQuery} onSearchChange={setSearchQuery} + favoritesData={favoritesData} /> diff --git a/src/components/resources/FolderPickerPopover.tsx b/src/components/resources/FolderPickerPopover.tsx new file mode 100644 index 0000000..edaf3c5 --- /dev/null +++ b/src/components/resources/FolderPickerPopover.tsx @@ -0,0 +1,212 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { IconFolder, IconPlus, IconAlertTriangle } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import { FavoriteFolder } from '@/hooks/useFavoriteFolders'; +import { MAX_FOLDER_ITEMS } from '@/hooks/useUserFavorites'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import FolderDialog from './FolderDialog'; +import { useFavoriteFolders } from '@/hooks/useFavoriteFolders'; + +interface FolderPickerPopoverProps { + isOpen: boolean; + onClose: () => void; + /** Called when user picks a folder (null = no folder / unsorted) */ + onSelectFolder: (folderId: string | null) => void; + folders: FavoriteFolder[]; + /** Returns how many items are in a folder */ + getFolderItemCount: (folderId: string | null) => number; + /** Position anchor element */ + anchorRef: React.RefObject; +} + +const FolderPickerPopover = ({ + isOpen, + onClose, + onSelectFolder, + folders, + getFolderItemCount, + anchorRef, +}: FolderPickerPopoverProps) => { + const popoverRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const { createFolder } = useFavoriteFolders(); + + // Position the popover relative to the anchor + useEffect(() => { + if (!isOpen || !anchorRef.current) return; + + const rect = anchorRef.current.getBoundingClientRect(); + const popoverWidth = 220; + const popoverHeight = 300; + + let top = rect.bottom + 8; + let left = rect.left + rect.width / 2 - popoverWidth / 2; + + // Keep within viewport + if (left < 8) left = 8; + if (left + popoverWidth > window.innerWidth - 8) { + left = window.innerWidth - popoverWidth - 8; + } + if (top + popoverHeight > window.innerHeight - 8) { + top = rect.top - popoverHeight - 8; + } + + setPosition({ top, left }); + }, [isOpen, anchorRef]); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + anchorRef.current && + !anchorRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + // Delay to avoid the current click event closing it + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose, anchorRef]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const rootFolders = folders.filter(f => !f.parent_id); + + const handleFolderClick = (folderId: string | null) => { + if (folderId) { + const count = getFolderItemCount(folderId); + if (count >= MAX_FOLDER_ITEMS) { + // Don't close, just don't select + return; + } + } + onSelectFolder(folderId); + onClose(); + }; + + const handleCreateFolder = async (name: string, color: string | null) => { + try { + const newFolder = await createFolder(name, null, color); + setShowCreateDialog(false); + if (newFolder?.id) { + onSelectFolder(String(newFolder.id)); + onClose(); + } + } catch { + setShowCreateDialog(false); + } + }; + + return ( + <> +
+
+

+ Save to folder +

+
+ + +
+ {/* No folder / unsorted option */} + + + {/* Folder list */} + {rootFolders.map(folder => { + const count = getFolderItemCount(folder.id); + const isFull = count >= MAX_FOLDER_ITEMS; + + return ( + + ); + })} +
+
+ + {/* Create folder */} +
+ +
+
+ + setShowCreateDialog(false)} + onSave={handleCreateFolder} + initialData={null} + mode="create" + /> + + ); +}; + +export default FolderPickerPopover; diff --git a/src/components/resources/ResourceCard.tsx b/src/components/resources/ResourceCard.tsx index ccf5b8d..6b779ca 100644 --- a/src/components/resources/ResourceCard.tsx +++ b/src/components/resources/ResourceCard.tsx @@ -15,6 +15,7 @@ import { getResourceUrl, Resource } from "@/types/resources"; import { cn } from "@/lib/utils"; import { useHeartedResources } from "@/hooks/useHeartedResources"; import AudioPlayer from "@/components/AudioPlayer"; +import FolderPickerPopover from "./FolderPickerPopover"; interface ResourceCardProps { resource: Resource; @@ -34,9 +35,11 @@ const ResourceCard = ({ resource, onClick, fontPreviewText }: ResourceCardProps) setIsImageLoaded(false); }, [resource.id]); - const { toggleHeart, isHearted } = useHeartedResources(); + const { toggleHeart, isHearted, moveFavorite, addFavoriteToFolder, getFolderItemCount, folders } = useHeartedResources(); const resourceUrl = getResourceUrl(resource); const isFavorite = isHearted(resourceUrl); + const [showFolderPicker, setShowFolderPicker] = useState(false); + const heartButtonRef = useRef(null); const getPreviewUrl = (resource: Resource) => { if (resource.download_url) return resource.download_url; @@ -142,11 +145,28 @@ const ResourceCard = ({ resource, onClick, fontPreviewText }: ResourceCardProps) }; const handleFavoriteClick = useCallback( - (e: React.MouseEvent) => { + async (e: React.MouseEvent) => { e.stopPropagation(); - toggleHeart(resourceUrl); + try { + const result = await toggleHeart(resourceUrl); + if (result.action === 'added' && folders.length > 0) { + setShowFolderPicker(true); + } + } catch { + // toggle failed, already toasted in the hook + } + }, + [toggleHeart, resourceUrl, folders.length], + ); + + const handleFolderSelect = useCallback( + (folderId: string | null) => { + if (folderId && resourceUrl) { + addFavoriteToFolder(resourceUrl, folderId); + } + setShowFolderPicker(false); }, - [toggleHeart, resourceUrl], + [addFavoriteToFolder, resourceUrl], ); const handlePreviewClick = (e: React.MouseEvent) => { @@ -302,6 +322,7 @@ const ResourceCard = ({ resource, onClick, fontPreviewText }: ResourceCardProps) + setShowFolderPicker(false)} + onSelectFolder={handleFolderSelect} + folders={folders} + getFolderItemCount={getFolderItemCount} + anchorRef={heartButtonRef} + /> + { const { user } = useAuth(); const userFavorites = useUserFavorites(); + const { folders } = useFavoriteFolders(); const localStorageKey = 'heartedResources'; - + const getLocalHeartedResources = useCallback((): string[] => { try { const stored = localStorage.getItem(localStorageKey); @@ -44,17 +46,19 @@ export const useHeartedResources = () => { }; }, [getLocalHeartedResources]); - const toggleHeart = (resource: Resource | string) => { + const toggleHeart = (resource: Resource | string): Promise<{ action: 'added' | 'removed' }> => { const resourceUrl = typeof resource === 'string' ? resource : getResourceUrl(resource); - if (!resourceUrl) return; + if (!resourceUrl) return Promise.resolve({ action: 'removed' }); const current = getLocalHeartedResources(); - const newHearted = current.includes(resourceUrl) + const isCurrentlyHearted = current.includes(resourceUrl); + const newHearted = isCurrentlyHearted ? current.filter(id => id !== resourceUrl) : [...current, resourceUrl]; - + setLocalHeartedResources(newHearted); setHeartedResources(newHearted); window.dispatchEvent(new CustomEvent('localFavoritesChanged')); + return Promise.resolve({ action: isCurrentlyHearted ? 'removed' : 'added' }); }; const isHearted = (resource: Resource | string) => { @@ -64,10 +68,10 @@ export const useHeartedResources = () => { }; if (user) { - const toggleHeart = (resource: Resource | string) => { + const toggleHeart = (resource: Resource | string): Promise<{ action: 'added' | 'removed' }> => { const resourceUrl = typeof resource === 'string' ? resource : getResourceUrl(resource); - if (!resourceUrl) return; - userFavorites.toggleFavorite(resourceUrl); + if (!resourceUrl) return Promise.resolve({ action: 'removed' }); + return userFavorites.toggleFavorite(resourceUrl); }; const isHearted = (resource: Resource | string) => { @@ -81,6 +85,10 @@ export const useHeartedResources = () => { toggleHeart, isHearted, isLoading: userFavorites.isLoading, + moveFavorite: userFavorites.moveFavorite, + addFavoriteToFolder: userFavorites.addFavoriteToFolder, + getFolderItemCount: userFavorites.getFolderItemCount, + folders, }; } @@ -89,5 +97,9 @@ export const useHeartedResources = () => { toggleHeart, isHearted, isLoading: false, + moveFavorite: (_resourceUrl: string, _folderId: string | null) => { }, + addFavoriteToFolder: (_resourceUrl: string, _folderId: string) => { }, + getFolderItemCount: (_folderId: string | null) => 0, + folders: [] as ReturnType['folders'], }; }; diff --git a/src/hooks/useUserFavorites.ts b/src/hooks/useUserFavorites.ts index d3f11e2..861e727 100644 --- a/src/hooks/useUserFavorites.ts +++ b/src/hooks/useUserFavorites.ts @@ -4,6 +4,8 @@ import { supabase } from '@/integrations/supabase/client'; import { useAuth } from './useAuth'; import { toast } from 'sonner'; +export const MAX_FOLDER_ITEMS = 40; + export interface UserFavorite { resource_url: string; folder_id: string | null; @@ -36,7 +38,7 @@ export const useUserFavorites = () => { throw error; } - return (data as UserFavorite[])?.filter(fav => fav.resource_url != null) || []; + return (data as unknown as UserFavorite[])?.filter(fav => fav.resource_url != null) || []; }, enabled: !!user?.id, staleTime: 1000 * 60 * 5, // Cache for 5 minutes @@ -58,7 +60,7 @@ export const useUserFavorites = () => { .eq('user_id', user.id) .eq('resource_url', resourceUrl); if (error) throw error; - return { action: 'removed', resourceUrl }; + return { action: 'removed' as const, resourceUrl }; } else { const { error } = await supabase .from('user_favorites') @@ -67,7 +69,7 @@ export const useUserFavorites = () => { { onConflict: 'user_id,resource_url', ignoreDuplicates: true } ); if (error) throw error; - return { action: 'added', resourceUrl }; + return { action: 'added' as const, resourceUrl }; } }, onSuccess: (data) => { @@ -85,12 +87,55 @@ export const useUserFavorites = () => { } }); + const addFavoriteToFolderMutation = useMutation({ + mutationFn: async ({ resourceUrl, folderId }: { resourceUrl: string; folderId: string }) => { + if (!user) throw new Error('User not authenticated'); + + // Check folder limit + const folderCount = favoritesData.filter(f => f.folder_id === folderId).length; + if (folderCount >= MAX_FOLDER_ITEMS) { + throw new Error('FOLDER_FULL'); + } + + // Upsert the favorite with the folder_id + const { error } = await supabase + .from('user_favorites') + .upsert( + { user_id: user.id, resource_url: resourceUrl, folder_id: folderId }, + { onConflict: 'user_id,resource_url' } + ); + if (error) throw error; + return { resourceUrl, folderId }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['userFavorites', user?.id] }); + toast.success('Added to folder'); + }, + onError: (error) => { + if (error instanceof Error && error.message === 'FOLDER_FULL') { + toast.error(`This folder already has ${MAX_FOLDER_ITEMS} items. Please create another folder.`); + return; + } + console.error('Error adding favorite to folder:', error); + toast.error('Failed to add to folder'); + } + }); + const moveFavoriteMutation = useMutation({ mutationFn: async ({ resourceUrl, folderId }: { resourceUrl: string, folderId: string | null }) => { if (!user) throw new Error('User not authenticated'); + // Check folder limit when moving to a folder (not when unassigning) + if (folderId) { + const folderCount = favoritesData.filter(f => f.folder_id === folderId).length; + if (folderCount >= MAX_FOLDER_ITEMS) { + throw new Error('FOLDER_FULL'); + } + } + const { error } = await supabase .from('user_favorites') + // @ts-ignore -- folder_id column exists in DB but generated types are stale .update({ folder_id: folderId }) .eq('user_id', user.id) .eq('resource_url', resourceUrl); @@ -103,25 +148,29 @@ export const useUserFavorites = () => { toast.success('Favorite moved to folder'); }, onError: (error) => { + if (error instanceof Error && error.message === 'FOLDER_FULL') { + toast.error(`This folder already has ${MAX_FOLDER_ITEMS} items. Please create another folder.`); + return; + } console.error('Error moving favorite:', error); toast.error('Failed to move favorite'); } }); - const toggleFavorite = (resourceUrl: string) => { + const toggleFavorite = (resourceUrl: string): Promise<{ action: 'added' | 'removed' }> => { if (!user) { toast.error('Please sign in to save favorites'); - return; + return Promise.resolve({ action: 'removed' }); } if (!resourceUrl) { toast.error('Unable to favorite this resource'); - return; + return Promise.resolve({ action: 'removed' }); } if (!isSchemaReady) { toast.error('Favorites storage needs a database update'); - return; + return Promise.resolve({ action: 'removed' }); } - toggleMutation.mutate(resourceUrl); + return toggleMutation.mutateAsync(resourceUrl); }; const moveFavorite = (resourceUrl: string, folderId: string | null) => { @@ -129,6 +178,15 @@ export const useUserFavorites = () => { moveFavoriteMutation.mutate({ resourceUrl, folderId }); }; + const addFavoriteToFolder = (resourceUrl: string, folderId: string) => { + if (!user || !resourceUrl) return; + addFavoriteToFolderMutation.mutate({ resourceUrl, folderId }); + }; + + const getFolderItemCount = (folderId: string | null): number => { + return favoritesData.filter(f => f.folder_id === folderId).length; + }; + const isFavorited = (resourceUrl: string) => favorites.includes(resourceUrl); return { @@ -137,6 +195,8 @@ export const useUserFavorites = () => { isLoading, toggleFavorite, moveFavorite, + addFavoriteToFolder, + getFolderItemCount, isFavorited, }; };