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 (
+ <>
+
+
+
+
+
+ {/* 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,
};
};