-
Notifications
You must be signed in to change notification settings - Fork 1
feat(favorites): migrate favorites to use resource URLs instead of IDs #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,56 +1,81 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useCallback, useEffect, useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useUserFavorites } from './useUserFavorites'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useAuth } from './useAuth'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getResourceUrl, Resource } from '@/types/resources'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const useHeartedResources = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { user } = useAuth(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userFavorites = useUserFavorites(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // If user is logged in, use user favorites, otherwise fall back to localStorage | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (user) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| heartedResources: userFavorites.favorites, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toggleHeart: userFavorites.toggleFavorite, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isHearted: userFavorites.isFavorited | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Legacy localStorage fallback for non-authenticated users | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const localStorageKey = 'heartedResources'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const getLocalHeartedResources = (): string[] => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const getLocalHeartedResources = useCallback((): string[] => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const stored = localStorage.getItem(localStorageKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return stored ? JSON.parse(stored) : []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setLocalHeartedResources = (resources: string[]) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| localStorage.setItem(localStorageKey, JSON.stringify(resources)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const heartedResources = getLocalHeartedResources(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [heartedResources, setHeartedResources] = useState<string[]>(() => getLocalHeartedResources()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const toggleHeart = (resourceId: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleLocalUpdate = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setHeartedResources(getLocalHeartedResources()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleStorage = (event: StorageEvent) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (event.key === localStorageKey) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setHeartedResources(getLocalHeartedResources()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.addEventListener('localFavoritesChanged', handleLocalUpdate); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.addEventListener('storage', handleStorage); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.removeEventListener('localFavoritesChanged', handleLocalUpdate); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.removeEventListener('storage', handleStorage); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [getLocalHeartedResources]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const toggleHeart = (resource: Resource | string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const resourceUrl = typeof resource === 'string' ? resource : getResourceUrl(resource); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!resourceUrl) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const current = getLocalHeartedResources(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const newHearted = current.includes(resourceId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? current.filter(id => id !== resourceId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : [...current, resourceId]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const newHearted = current.includes(resourceUrl) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? current.filter(id => id !== resourceUrl) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : [...current, resourceUrl]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setLocalHeartedResources(newHearted); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Force re-render by dispatching a custom event | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setHeartedResources(newHearted); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.dispatchEvent(new CustomEvent('localFavoritesChanged')); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isHearted = (resourceId: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return getLocalHeartedResources().includes(resourceId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isHearted = (resource: Resource | string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const resourceUrl = typeof resource === 'string' ? resource : getResourceUrl(resource); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!resourceUrl) return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return getLocalHeartedResources().includes(resourceUrl); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (user) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| heartedResources: userFavorites.favorites, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toggleHeart: userFavorites.toggleFavorite, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isHearted: userFavorites.isFavorited, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isLoading: userFavorites.isLoading, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's inspect the useHeartedResources.ts file to see the actual code
cat -n src/hooks/useHeartedResources.tsRepository: Coder-soft/renderdragon.org Length of output: 3198 🏁 Script executed: # Check how toggleHeart and isHearted are used in the codebase
rg -n --type=ts --type=tsx 'toggleHeart\|isHearted' -g '!**/useHeartedResources*' --max-count=100Repository: Coder-soft/renderdragon.org Length of output: 98 🏁 Script executed: # Let's also check the useUserFavorites hook to understand the parameter types
fd -e ts -e tsx 'useUserFavorites' --exec cat -n {} +Repository: Coder-soft/renderdragon.org Length of output: 4206 🏁 Script executed: # Check the types of Resource and getResourceUrl
rg -n 'type Resource|interface Resource|export.*getResourceUrl' --type=ts --type=tsxRepository: Coder-soft/renderdragon.org Length of output: 98 🏁 Script executed: # Search for toggleHeart usage with proper syntax
rg -n 'toggleHeart' --type ts -g '!useHeartedResources' -A 3 -B 1Repository: Coder-soft/renderdragon.org Length of output: 1984 🏁 Script executed: # Search for isHearted usage
rg -n 'isHearted' --type ts -g '!useHeartedResources' -A 3 -B 1Repository: Coder-soft/renderdragon.org Length of output: 1653 🏁 Script executed: # Check getResourceUrl implementation
rg -n 'getResourceUrl|export.*Resource' --type ts | head -40Repository: Coder-soft/renderdragon.org Length of output: 2997 Critical: Authenticated path doesn't resolve The unauthenticated
While current codebase usage (e.g., Proposed fix: Wrap authenticated methods with URL resolution if (user) {
+ const resolveUrl = (resource: Resource | string): string =>
+ typeof resource === 'string' ? resource : getResourceUrl(resource);
return {
heartedResources: userFavorites.favorites,
- toggleHeart: userFavorites.toggleFavorite,
- isHearted: userFavorites.isFavorited,
+ toggleHeart: (resource: Resource | string) => {
+ const url = resolveUrl(resource);
+ if (!url) return;
+ userFavorites.toggleFavorite(url);
+ },
+ isHearted: (resource: Resource | string) => {
+ const url = resolveUrl(resource);
+ if (!url) return false;
+ return userFavorites.isFavorited(url);
+ },
isLoading: userFavorites.isLoading,
};
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| heartedResources, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toggleHeart, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isHearted | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isHearted, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isLoading: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,80 +1,105 @@ | ||||||
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; | ||||||
| import { useState } from 'react'; | ||||||
| import { supabase } from '@/integrations/supabase/client'; | ||||||
| import { useAuth } from './useAuth'; | ||||||
| import { toast } from 'sonner'; | ||||||
|
|
||||||
| export const useUserFavorites = () => { | ||||||
| const { user } = useAuth(); | ||||||
| const queryClient = useQueryClient(); | ||||||
| const [isSchemaReady, setIsSchemaReady] = useState(true); | ||||||
|
|
||||||
| const { data: favorites = [], isLoading } = useQuery({ | ||||||
| queryKey: ['userFavorites', user?.id], | ||||||
| queryFn: async () => { | ||||||
| if (!user?.id) return []; | ||||||
| if (!isSchemaReady) return []; | ||||||
|
|
||||||
| const { data, error } = await supabase | ||||||
| .from('user_favorites') | ||||||
| .select('resource_id') | ||||||
| .select('resource_url') | ||||||
| .eq('user_id', user.id); | ||||||
|
|
||||||
| if (error) { | ||||||
| if (error.code === '42703' || error.message.includes('resource_url')) { | ||||||
| setIsSchemaReady(false); | ||||||
| toast.error('Favorites storage needs a database update'); | ||||||
| return []; | ||||||
| } | ||||||
| console.error('Error fetching favorites:', error); | ||||||
| toast.error('Failed to load favorites'); | ||||||
| throw error; | ||||||
| } | ||||||
|
|
||||||
| return data?.map(fav => fav.resource_id.toString()) || []; | ||||||
| return data?.map(fav => fav.resource_url.toString()) || []; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The schema enforces NOT NULL after the final migration, but if the migration sequence is partially applied, Defensive fix- return data?.map(fav => fav.resource_url.toString()) || [];
+ return data?.map(fav => String(fav.resource_url ?? '')) .filter(Boolean) || [];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| }, | ||||||
| enabled: !!user?.id, | ||||||
| staleTime: 1000 * 60 * 5, // Cache for 5 minutes | ||||||
| }); | ||||||
|
|
||||||
| const toggleMutation = useMutation({ | ||||||
| mutationFn: async (resourceId: string) => { | ||||||
| mutationFn: async (resourceUrl: string) => { | ||||||
| if (!user) throw new Error('User not authenticated'); | ||||||
| if (!isSchemaReady) throw new Error('Favorites storage needs a database update'); | ||||||
|
|
||||||
| const isFavorited = favorites.includes(resourceId); | ||||||
| const isFavorited = favorites.includes(resourceUrl); | ||||||
|
|
||||||
| if (isFavorited) { | ||||||
| const { error } = await supabase | ||||||
| .from('user_favorites') | ||||||
| .delete() | ||||||
| .eq('user_id', user.id) | ||||||
| .eq('resource_id', resourceId); | ||||||
| .eq('resource_url', resourceUrl); | ||||||
| if (error) throw error; | ||||||
| return { action: 'removed', resourceId }; | ||||||
| return { action: 'removed', resourceUrl }; | ||||||
| } else { | ||||||
| const { error } = await supabase | ||||||
| .from('user_favorites') | ||||||
| .insert({ user_id: user.id, resource_id: resourceId }); | ||||||
| .upsert( | ||||||
| { user_id: user.id, resource_url: resourceUrl }, | ||||||
| { onConflict: 'user_id,resource_url', ignoreDuplicates: true } | ||||||
| ); | ||||||
| if (error) throw error; | ||||||
| return { action: 'added', resourceId }; | ||||||
| return { action: 'added', resourceUrl }; | ||||||
| } | ||||||
| }, | ||||||
| onSuccess: (data) => { | ||||||
| queryClient.invalidateQueries({ queryKey: ['userFavorites', user?.id] }); | ||||||
| toast.success(data.action === 'added' ? 'Added to favorites' : 'Removed from favorites'); | ||||||
| }, | ||||||
| onError: (error) => { | ||||||
| const errorMessage = error instanceof Error ? error.message : ''; | ||||||
| if (errorMessage.includes('database update')) { | ||||||
| toast.error('Favorites storage needs a database update'); | ||||||
| return; | ||||||
| } | ||||||
| console.error('Error toggling favorite:', error); | ||||||
| toast.error('Failed to update favorites'); | ||||||
| } | ||||||
| }); | ||||||
|
|
||||||
| const toggleFavorite = (resourceId: string) => { | ||||||
| const toggleFavorite = (resourceUrl: string) => { | ||||||
| if (!user) { | ||||||
| toast.error('Please sign in to save favorites'); | ||||||
| return; | ||||||
| } | ||||||
| toggleMutation.mutate(resourceId); | ||||||
| if (!resourceUrl) { | ||||||
| toast.error('Unable to favorite this resource'); | ||||||
| return; | ||||||
| } | ||||||
| if (!isSchemaReady) { | ||||||
| toast.error('Favorites storage needs a database update'); | ||||||
| return; | ||||||
| } | ||||||
| toggleMutation.mutate(resourceUrl); | ||||||
| }; | ||||||
|
|
||||||
| const isFavorited = (resourceId: string) => favorites.includes(resourceId); | ||||||
| const isFavorited = (resourceUrl: string) => favorites.includes(resourceUrl); | ||||||
|
|
||||||
| return { | ||||||
| favorites, | ||||||
| isLoading, | ||||||
| toggleFavorite, | ||||||
| isFavorited, | ||||||
| }; | ||||||
| }; | ||||||
| }; | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate React keys possible if two favorite resources share the same URL.
getResourceUrl(resource)is used as thekeyprop (line 52). While uncommon, if two distinct resources resolve to the same URL (e.g., samedownload_url), React would see duplicate keys and log warnings / mis-reconcile. Using a composite key like`${resource.id}-${resourceUrl}`is safer.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents