Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/AdBlockDetector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export function AdBlockDetector() {

try {
const host = import.meta.env.VITE_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com';
const apiKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY;
if (!apiKey) {
return;
}
// Try fetching the /decide endpoint which is critical for PostHog and often blocked
// We use string concatenation to ensure the URL is well-formed
const url = `${host}/decide?v=3&ip=1&_=`;
Expand All @@ -45,7 +49,7 @@ export function AdBlockDetector() {
await fetch(url + Date.now(), {
method: 'POST', // POST requests to tracking endpoints are more likely to be blocked
mode: 'no-cors',
body: JSON.stringify({ token: 'test' })
body: JSON.stringify({ token: apiKey })
});

} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ const Navbar = () => {
const navigate = useNavigate();

const handleShowFavorites = () => {
navigate("/account");
navigate("/resources?tab=favorites");
};

const handleMobileCollapsibleToggle = (name: string) => {
Expand Down
25 changes: 16 additions & 9 deletions src/components/resources/FavoritesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import { motion } from 'framer-motion';
import { useUserFavorites } from '@/hooks/useUserFavorites';
import { useHeartedResources } from '@/hooks/useHeartedResources';
import { useResources } from '@/hooks/useResources';
import { getResourceUrl } from '@/types/resources';
import ResourceCard from './ResourceCard';
import ResourceCardSkeleton from './ResourceCardSkeleton';
import { IconHeart } from '@tabler/icons-react';

const FavoritesTab = () => {
const { favorites, isLoading: favoritesLoading } = useUserFavorites();
const { heartedResources, isLoading: favoritesLoading } = useHeartedResources();
const { resources, isLoading: resourcesLoading, setSelectedResource } = useResources();

const isLoading = favoritesLoading || resourcesLoading;

const favoriteResources = resources.filter(resource => favorites.includes(String(resource.id)));
const favoriteResources = resources.filter(resource => {
const resourceUrl = getResourceUrl(resource);
return resourceUrl ? heartedResources.includes(resourceUrl) : false;
});

if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 8 }).map((_, idx) => (
<ResourceCardSkeleton key={`fav-skel-${idx}`} />
))}
Expand All @@ -40,16 +44,19 @@ const FavoritesTab = () => {
}

return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{favoriteResources.map(resource => (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{favoriteResources.map(resource => {
const resourceUrl = getResourceUrl(resource);
return (
<ResourceCard
key={resource.id}
key={resourceUrl}
resource={resource}
onClick={setSelectedResource}
/>
))}
);
})}
</div>
Comment on lines 46 to 58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Duplicate React keys possible if two favorite resources share the same URL.

getResourceUrl(resource) is used as the key prop (line 52). While uncommon, if two distinct resources resolve to the same URL (e.g., same download_url), React would see duplicate keys and log warnings / mis-reconcile. Using a composite key like `${resource.id}-${resourceUrl}` is safer.

Suggested fix
-          key={resourceUrl}
+          key={`${resource.id}-${resourceUrl}`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{favoriteResources.map(resource => (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{favoriteResources.map(resource => {
const resourceUrl = getResourceUrl(resource);
return (
<ResourceCard
key={resource.id}
key={resourceUrl}
resource={resource}
onClick={setSelectedResource}
/>
))}
);
})}
</div>
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{favoriteResources.map(resource => {
const resourceUrl = getResourceUrl(resource);
return (
<ResourceCard
key={`${resource.id}-${resourceUrl}`}
resource={resource}
onClick={setSelectedResource}
/>
);
})}
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/resources/FavoritesTab.tsx` around lines 46 - 58, The current
key for ResourceCard uses getResourceUrl(resource) which can collide for
different favorites; update the key in the favorites rendering to use a
composite key combining a unique resource identifier with the URL (for example
use resource.id plus the getResourceUrl(resource) value) so change the key on
the ResourceCard rendered inside favoriteResources.map to a composite like
`${resource.id}-${resourceUrl}` to guarantee uniqueness while keeping the rest
of the render (ResourceCard, resource prop, onClick={setSelectedResource})
unchanged.

);
};

export default FavoritesTab;
export default FavoritesTab;
16 changes: 7 additions & 9 deletions src/components/resources/ResourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import {
IconHeart,
IconBoxModel,
} from "@tabler/icons-react";
import { Resource } from "@/types/resources";
import { getResourceUrl, Resource } from "@/types/resources";
import { cn } from "@/lib/utils";
import { useUserFavorites } from "@/hooks/useUserFavorites";
import { useAuth } from "@/hooks/useAuth";
import { useHeartedResources } from "@/hooks/useHeartedResources";
import AudioPlayer from "@/components/AudioPlayer";

interface ResourceCardProps {
Expand All @@ -29,15 +28,14 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
const stored = localStorage.getItem('hoverToPlay');
return stored === null ? true : stored === 'true';
});
const { user } = useAuth();

// Reset image loaded state when resource changes
useEffect(() => {
setIsImageLoaded(false);
}, [resource.id]);

const { toggleFavorite, isFavorited } = useUserFavorites();
const isFavorite = isFavorited(String(resource.id));
const { toggleHeart, isHearted } = useHeartedResources();
const resourceUrl = getResourceUrl(resource);
const isFavorite = isHearted(resourceUrl);

const getPreviewUrl = (resource: Resource) => {
if (resource.download_url) return resource.download_url;
Expand Down Expand Up @@ -141,9 +139,9 @@ const ResourceCard = ({ resource, onClick }: ResourceCardProps) => {
const handleFavoriteClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(String(resource.id));
toggleHeart(resourceUrl);
},
[toggleFavorite, resource.id],
[toggleHeart, resourceUrl],
);

const handlePreviewClick = (e: React.MouseEvent) => {
Expand Down
69 changes: 47 additions & 22 deletions src/hooks/useHeartedResources.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's inspect the useHeartedResources.ts file to see the actual code
cat -n src/hooks/useHeartedResources.ts

Repository: 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=100

Repository: 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=tsx

Repository: 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 1

Repository: Coder-soft/renderdragon.org

Length of output: 1984


🏁 Script executed:

# Search for isHearted usage
rg -n 'isHearted' --type ts -g '!useHeartedResources' -A 3 -B 1

Repository: Coder-soft/renderdragon.org

Length of output: 1653


🏁 Script executed:

# Check getResourceUrl implementation
rg -n 'getResourceUrl|export.*Resource' --type ts | head -40

Repository: Coder-soft/renderdragon.org

Length of output: 2997


Critical: Authenticated path doesn't resolve Resource objects—type signature inconsistency.

The unauthenticated toggleHeart and isHearted accept Resource | string and resolve to a URL via getResourceUrl. However, the authenticated path directly returns userFavorites.toggleFavorite and userFavorites.isFavorited, which only accept string. This creates an inconsistent API contract:

  • Unauthenticated: toggleHeart(resource: Resource | string) → resolves Resource to URL
  • Authenticated: toggleHeart(resourceUrl: string) → expects string only

While current codebase usage (e.g., ResourceCard.tsx line 37) pre-resolves Resource objects before calling these functions, the type mismatch violates the API contract. A developer passing a Resource object when authenticated would pass [object Object] to the database.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (user) {
return {
heartedResources: userFavorites.favorites,
toggleHeart: userFavorites.toggleFavorite,
isHearted: userFavorites.isFavorited,
isLoading: userFavorites.isLoading,
};
}
if (user) {
const resolveUrl = (resource: Resource | string): string =>
typeof resource === 'string' ? resource : getResourceUrl(resource);
return {
heartedResources: userFavorites.favorites,
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,
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useHeartedResources.ts` around lines 66 - 73, Authenticated branch
returns userFavorites.toggleFavorite and userFavorites.isFavorited which only
accept string, causing an inconsistent API compared to the unauthenticated
branch that accepts Resource | string and resolves via getResourceUrl; fix by
wrapping the authenticated handlers so toggleHeart(resource: Resource | string)
first resolves to a URL (use getResourceUrl or the same resolution helper used
in the unauthenticated branch) and then calls
userFavorites.toggleFavorite(resolvedUrl), and similarly wrap isHearted to
resolve the resource before calling userFavorites.isFavorited(resolvedUrl); keep
heartedResources and isLoading unchanged and preserve original function names
userFavorites.toggleFavorite and userFavorites.isFavorited in the
implementation.


return {
heartedResources,
toggleHeart,
isHearted
isHearted,
isLoading: false,
};
};
49 changes: 37 additions & 12 deletions src/hooks/useUserFavorites.ts
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()) || [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

fav.resource_url could be null at runtime if migrations haven't fully run.

The schema enforces NOT NULL after the final migration, but if the migration sequence is partially applied, resource_url may still be nullable. .toString() on null throws a TypeError.

Defensive fix
-      return data?.map(fav => fav.resource_url.toString()) || [];
+      return data?.map(fav => String(fav.resource_url ?? '')) .filter(Boolean) || [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return data?.map(fav => fav.resource_url.toString()) || [];
return data?.map(fav => String(fav.resource_url ?? '')) .filter(Boolean) || [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useUserFavorites.ts` at line 34, In useUserFavorites (the data
mapping that currently does return data?.map(fav => fav.resource_url.toString())
|| []), guard against fav.resource_url being null before calling .toString():
filter out or handle null resource_url values (e.g., skip nulls or coerce
safely) so you never call .toString() on null and the function always returns an
array of strings; update the mapping to check fav.resource_url (or use a safe
coercion) and ensure the function still returns [] when data is absent.

},
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,
};
};
};
6 changes: 3 additions & 3 deletions src/integrations/supabase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,19 @@ export type Database = {
Row: {
created_at: string
id: string
resource_id: string
resource_url: string
user_id: string
}
Insert: {
created_at?: string
id?: string
resource_id: string
resource_url: string
user_id: string
}
Update: {
created_at?: string
id?: string
resource_id?: string
resource_url?: string
user_id?: string
}
Relationships: []
Expand Down
Loading