612 lines
20 KiB
TypeScript
612 lines
20 KiB
TypeScript
import { UserProfile } from '../pages/Post/types';
|
|
import MediaCard from "./MediaCard";
|
|
import React, { useEffect, useState, useRef } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { usePostNavigation } from "@/hooks/usePostNavigation";
|
|
import { useOrganization } from "@/contexts/OrganizationContext";
|
|
import { useFeedCache } from "@/contexts/FeedCacheContext";
|
|
import { useLayoutEffect } from "react";
|
|
|
|
import { useFeedData } from "@/hooks/useFeedData";
|
|
|
|
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
|
|
import { UploadCloud, Maximize, FolderTree } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import type { MediaType } from "@/types";
|
|
import type { CardPreset } from "@/modules/pages/PageCard";
|
|
|
|
export interface MediaItemType {
|
|
id: string;
|
|
picture_id?: string;
|
|
title: string;
|
|
description: string | null;
|
|
image_url: string;
|
|
thumbnail_url: string | null;
|
|
type: MediaType;
|
|
meta: any | null;
|
|
likes_count: number;
|
|
created_at: string;
|
|
user_id: string;
|
|
comments: { count: number }[];
|
|
|
|
author?: UserProfile;
|
|
job?: any;
|
|
responsive?: any; // Add responsive data
|
|
versionCount?: number;
|
|
}
|
|
|
|
import type { FeedSortOption } from '@/hooks/useFeedData';
|
|
import { mapFeedPostsToMediaItems, fetchPostById } from "@/modules/posts/client-posts";
|
|
import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
|
|
|
|
|
|
interface MediaGridProps {
|
|
customPictures?: MediaItemType[];
|
|
customLoading?: boolean;
|
|
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget';
|
|
navigationSourceId?: string;
|
|
isOwner?: boolean;
|
|
onFilesDrop?: (files: File[]) => void;
|
|
showVideos?: boolean; // Toggle video display (kept for backward compatibility)
|
|
sortBy?: FeedSortOption;
|
|
supabaseClient?: any;
|
|
apiUrl?: string;
|
|
categorySlugs?: string[];
|
|
preset?: CardPreset;
|
|
}
|
|
|
|
const DEFAULT_PRESET: CardPreset = { showTitle: true, showDescription: true };
|
|
|
|
const MediaGrid = ({
|
|
customPictures,
|
|
customLoading,
|
|
navigationSource = 'home',
|
|
navigationSourceId,
|
|
isOwner = false,
|
|
onFilesDrop,
|
|
showVideos = true,
|
|
sortBy = 'latest',
|
|
supabaseClient,
|
|
apiUrl,
|
|
categorySlugs,
|
|
preset = DEFAULT_PRESET
|
|
}: MediaGridProps) => {
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
const { setNavigationData, navigationData } = usePostNavigation();
|
|
const { getCache, saveCache } = useFeedCache();
|
|
|
|
|
|
|
|
const { orgSlug, isOrgContext } = useOrganization();
|
|
// State definitions restored
|
|
const [mediaItems, setMediaItems] = useState<MediaItemType[]>([]);
|
|
const [userLikes, setUserLikes] = useState<Set<string>>(new Set());
|
|
const [loading, setLoading] = useState(true);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragLeaveTimeoutRef = useRef<number | null>(null);
|
|
|
|
// 1. Data Fetching
|
|
const {
|
|
posts: feedPosts,
|
|
loading: feedLoading,
|
|
hasMore,
|
|
loadMore,
|
|
isFetchingMore
|
|
} = useFeedData({
|
|
source: navigationSource,
|
|
sourceId: navigationSourceId,
|
|
isOrgContext,
|
|
orgSlug,
|
|
sortBy,
|
|
categorySlugs,
|
|
// Disable hook if we have custom pictures
|
|
enabled: !customPictures,
|
|
supabaseClient
|
|
});
|
|
// Infinite Scroll Observer
|
|
const observerTarget = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const observer = new IntersectionObserver(
|
|
entries => {
|
|
if (entries[0].isIntersecting && hasMore && !feedLoading && !isFetchingMore) {
|
|
loadMore();
|
|
}
|
|
},
|
|
{ threshold: 1.0 }
|
|
);
|
|
|
|
if (observerTarget.current) {
|
|
observer.observe(observerTarget.current);
|
|
}
|
|
|
|
return () => {
|
|
if (observerTarget.current) {
|
|
observer.unobserve(observerTarget.current);
|
|
}
|
|
};
|
|
}, [hasMore, feedLoading, isFetchingMore, loadMore]);
|
|
|
|
// 2. State & Effects
|
|
useEffect(() => {
|
|
let finalMedia: MediaItemType[] = [];
|
|
|
|
if (customPictures) {
|
|
finalMedia = customPictures;
|
|
setLoading(customLoading || false);
|
|
} else {
|
|
// Map FeedPost[] -> MediaItemType[]
|
|
finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy);
|
|
setLoading(feedLoading);
|
|
}
|
|
|
|
setMediaItems(finalMedia);
|
|
|
|
// Update Navigation Data
|
|
if (finalMedia.length > 0) {
|
|
const navData = {
|
|
posts: finalMedia.map(item => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
image_url: item.image_url,
|
|
user_id: item.user_id,
|
|
type: normalizeMediaType(item.type)
|
|
})),
|
|
currentIndex: 0,
|
|
source: navigationSource,
|
|
sourceId: navigationSourceId
|
|
};
|
|
setNavigationData(navData);
|
|
}
|
|
}, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]);
|
|
|
|
// Scroll Restoration Logic
|
|
const cacheKey = `${navigationSource}-${navigationSourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
|
|
const hasRestoredScroll = useRef(false);
|
|
|
|
// Restore scroll when mediaItems are populated
|
|
useLayoutEffect(() => {
|
|
// Enable restoration if we have items, haven't restored yet, and either it's NOT custom pictures OR it IS a widget with an ID
|
|
const shouldRestore = mediaItems.length > 0 &&
|
|
!hasRestoredScroll.current &&
|
|
(!customPictures || (navigationSource === 'widget' && navigationSourceId));
|
|
|
|
if (shouldRestore) {
|
|
const cached = getCache(cacheKey);
|
|
|
|
if (cached && cached.scrollY > 0) {
|
|
window.scrollTo(0, cached.scrollY);
|
|
}
|
|
hasRestoredScroll.current = true;
|
|
}
|
|
}, [mediaItems, cacheKey, getCache, customPictures, navigationSource, navigationSourceId]);
|
|
|
|
// Reset restored flag when source changes
|
|
useEffect(() => {
|
|
hasRestoredScroll.current = false;
|
|
}, [cacheKey]);
|
|
|
|
// Track scroll position
|
|
const lastScrollY = useRef(window.scrollY);
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
lastScrollY.current = window.scrollY;
|
|
};
|
|
window.addEventListener('scroll', handleScroll);
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
// Save scroll position on unmount or before source change
|
|
useEffect(() => {
|
|
return () => {
|
|
// Save if not custom pictures OR if it is a widget with an ID
|
|
const shouldSave = !customPictures || (navigationSource === 'widget' && navigationSourceId);
|
|
|
|
if (shouldSave) {
|
|
const cached = getCache(cacheKey); // Get latest data from cache (posts etc)
|
|
// Use lastScrollY instead of window.scrollY because unmount might happen after a scroll-to-top by router
|
|
|
|
if (cached && lastScrollY.current > 0) {
|
|
saveCache(cacheKey, { ...cached, scrollY: lastScrollY.current });
|
|
}
|
|
}
|
|
};
|
|
}, [cacheKey, getCache, saveCache, customPictures, navigationSource, navigationSourceId]);
|
|
|
|
const fetchMediaFromPicturesTable = async () => {
|
|
// Manual Refresh Stub if needed - for onDelete/refresh actions
|
|
// Since the hook data is reactive, we might need a refresh function from the hook
|
|
// But for now, we can just reload the page or implement refresh in future.
|
|
window.location.reload();
|
|
};
|
|
|
|
const fetchUserLikes = async () => {
|
|
if (!user || mediaItems.length === 0) return;
|
|
|
|
try {
|
|
const { pictureLikes } = await fetchUserMediaLikes(user.id);
|
|
|
|
// Filter to only displayed items
|
|
const targetIds = new Set(
|
|
mediaItems.map(item => item.picture_id || item.id).filter(Boolean)
|
|
);
|
|
|
|
setUserLikes(prev => {
|
|
const newSet = new Set(prev);
|
|
pictureLikes.forEach(id => {
|
|
if (targetIds.has(id)) newSet.add(id);
|
|
});
|
|
return newSet;
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching user likes:', error);
|
|
}
|
|
};
|
|
|
|
const handleMediaClick = (mediaId: string, type: MediaType, index: number) => {
|
|
console.log('handleMediaClick', mediaId, type, index);
|
|
// Handle Page navigation
|
|
if (type === 'page-intern') {
|
|
const item = mediaItems.find(i => i.id === mediaId);
|
|
|
|
if (item && item.meta?.slug) {
|
|
navigate(`/user/${item.author?.username || item.user_id}/pages/${item.meta.slug}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update navigation data with current index for correct Prev/Next behavior
|
|
if (navigationData) {
|
|
setNavigationData({ ...navigationData, currentIndex: index });
|
|
}
|
|
navigate(`/post/${mediaId}`);
|
|
};
|
|
|
|
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (dragLeaveTimeoutRef.current) {
|
|
clearTimeout(dragLeaveTimeoutRef.current);
|
|
}
|
|
if (isOwner && onFilesDrop) setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (dragLeaveTimeoutRef.current) {
|
|
clearTimeout(dragLeaveTimeoutRef.current);
|
|
}
|
|
dragLeaveTimeoutRef.current = window.setTimeout(() => {
|
|
setIsDragging(false);
|
|
}, 100);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
|
|
if (!isOwner || !onFilesDrop) return;
|
|
|
|
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'));
|
|
if (files.length > 0) {
|
|
onFilesDrop(files);
|
|
}
|
|
};
|
|
|
|
const handleEditPost = async (postId: string) => {
|
|
try {
|
|
const toastId = toast.loading('Loading post for editing...');
|
|
|
|
// Fetch full post via API (returns FeedPost with pictures)
|
|
const post = await fetchPostById(postId);
|
|
|
|
if (!post) throw new Error('Post not found');
|
|
|
|
if (!post.pictures || post.pictures.length === 0) {
|
|
throw new Error('No pictures found in post');
|
|
}
|
|
|
|
// Transform pictures for wizard
|
|
const wizardImages = post.pictures
|
|
.sort((a: any, b: any) => ((a.position || 0) - (b.position || 0)))
|
|
.map((p: any) => ({
|
|
id: p.id,
|
|
path: p.id,
|
|
src: p.image_url,
|
|
title: p.title,
|
|
description: p.description || '',
|
|
selected: false,
|
|
realDatabaseId: p.id
|
|
}));
|
|
|
|
toast.dismiss(toastId);
|
|
|
|
navigate('/wizard', {
|
|
state: {
|
|
mode: 'post',
|
|
initialImages: wizardImages,
|
|
postTitle: post.title,
|
|
postDescription: post.description,
|
|
editingPostId: post.id
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error opening post editor:', error);
|
|
toast.error('Failed to open post editor');
|
|
}
|
|
};
|
|
|
|
|
|
// Set up navigation data when media items change (for custom media)
|
|
useEffect(() => {
|
|
if (mediaItems.length > 0 && customPictures) {
|
|
const navData = {
|
|
posts: mediaItems
|
|
.filter(item => !isVideoType(normalizeMediaType(item.type)))
|
|
.map(item => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
image_url: item.image_url,
|
|
user_id: item.user_id
|
|
})),
|
|
currentIndex: 0,
|
|
source: navigationSource,
|
|
sourceId: navigationSourceId
|
|
};
|
|
setNavigationData(navData);
|
|
}
|
|
}, [mediaItems, customPictures, navigationSource, navigationSourceId, setNavigationData]);
|
|
|
|
// Group media items by category
|
|
// - When filtering by category: group by immediate subcategories (show parent + each child)
|
|
// - When on home feed: don't group (flat list)
|
|
const shouldGroupByCategory = !!categorySlugs && categorySlugs.length > 0;
|
|
|
|
const groupedItems = React.useMemo(() => {
|
|
if (!shouldGroupByCategory) {
|
|
return { sections: [{ key: 'all', title: null, items: mediaItems }] };
|
|
}
|
|
|
|
const grouped = new Map<string, MediaItemType[]>();
|
|
const parentCategoryItems: MediaItemType[] = [];
|
|
|
|
mediaItems.forEach(item => {
|
|
// Find corresponding feed post to get category_paths
|
|
const feedPost = feedPosts.find(p => p.id === item.id || p.cover?.id === item.id);
|
|
|
|
if (!feedPost?.category_paths || feedPost.category_paths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Check if item has an exact match to the parent category (path length = 1)
|
|
const hasExactParentMatch = feedPost.category_paths.some((path: any[]) =>
|
|
path.length === 1 && categorySlugs.includes(path[0].slug)
|
|
);
|
|
|
|
if (hasExactParentMatch) {
|
|
// Item is directly in the parent category
|
|
parentCategoryItems.push(item);
|
|
return;
|
|
}
|
|
|
|
// Otherwise, find the most specific path (longest) that includes the filtered category
|
|
let mostSpecificPath: any[] | null = null;
|
|
let categoryIndex = -1;
|
|
|
|
for (const path of feedPost.category_paths) {
|
|
const idx = path.findIndex((cat: any) => categorySlugs.includes(cat.slug));
|
|
if (idx !== -1) {
|
|
if (!mostSpecificPath || path.length > mostSpecificPath.length) {
|
|
mostSpecificPath = path;
|
|
categoryIndex = idx;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!mostSpecificPath || categoryIndex === -1) return;
|
|
|
|
// Item is in a subcategory - use the immediate child category
|
|
const subcategory = mostSpecificPath[categoryIndex + 1];
|
|
if (subcategory) {
|
|
const key = subcategory.slug;
|
|
|
|
if (!grouped.has(key)) {
|
|
grouped.set(key, []);
|
|
}
|
|
grouped.get(key)!.push(item);
|
|
}
|
|
});
|
|
|
|
const sections = [];
|
|
|
|
// Add parent category section first (if it has items)
|
|
if (parentCategoryItems.length > 0) {
|
|
sections.push({
|
|
key: categorySlugs[0],
|
|
title: categorySlugs[0].replace(/-/g, ' '),
|
|
items: parentCategoryItems
|
|
});
|
|
}
|
|
|
|
// Add subcategory sections
|
|
Array.from(grouped.entries()).forEach(([key, items]) => {
|
|
sections.push({
|
|
key,
|
|
title: key.replace(/-/g, ' '),
|
|
items
|
|
});
|
|
});
|
|
|
|
// If no sections, return all items in one section
|
|
if (sections.length === 0) {
|
|
return { sections: [{ key: 'all', title: null, items: mediaItems }] };
|
|
}
|
|
|
|
return { sections };
|
|
}, [mediaItems, feedPosts, shouldGroupByCategory, categorySlugs, navigationSource]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="py-8">
|
|
<div className="text-center text-muted-foreground">
|
|
Loading media...
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasItems = mediaItems.length > 0;
|
|
return (
|
|
<div className="w-full relative">
|
|
{hasItems && isOwner && onFilesDrop && navigationSource === 'collection' && (
|
|
<div
|
|
className={`my-4 border-2 border-dashed border-muted rounded-lg p-6 text-center text-muted-foreground transition-all ${isDragging ? 'ring-4 ring-primary ring-offset-2 border-solid border-primary bg-primary/5' : ''
|
|
} `}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
>
|
|
<UploadCloud className="mx-auto h-8 w-8 text-muted-foreground" />
|
|
<p className="mt-2 text-sm">Drag and drop more images here to upload.</p>
|
|
</div>
|
|
)}
|
|
|
|
{!hasItems ? (
|
|
isOwner && navigationSource === 'collection' && onFilesDrop ? (
|
|
<div
|
|
className="py-8"
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className={`border-2 border-dashed border-muted rounded-lg p-12 text-center text-muted-foreground transition-all ${isDragging ? 'ring-4 ring-primary ring-offset-2 border-solid border-primary bg-primary/5' : ''
|
|
} `}>
|
|
<UploadCloud className="mx-auto h-12 w-12 text-muted-foreground" />
|
|
<h3 className="mt-4 text-lg font-semibold">This collection is empty</h3>
|
|
<p className="mt-2">Drag and drop images here to get started.</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-8">
|
|
<div className="text-center text-muted-foreground">
|
|
<p className="text-lg">No media yet!</p>
|
|
<p>Be the first to share content with the community.</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<>
|
|
{groupedItems.sections.map((section) => (
|
|
<div key={section.key} className="mb-8">
|
|
{section.title && (
|
|
<h2 className="text-lg font-semibold mb-4 px-4 flex items-center gap-2 capitalize">
|
|
<FolderTree className="h-5 w-5" />
|
|
{section.title}
|
|
</h2>
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4">
|
|
{section.items.map((item, index) => {
|
|
const itemType = normalizeMediaType(item.type);
|
|
const isVideo = isVideoType(itemType);
|
|
// For images, convert URL to optimized format
|
|
const displayUrl = item.image_url;
|
|
if (isVideo) {
|
|
return (
|
|
<div key={item.id} className="relative group">
|
|
<MediaCard
|
|
id={item.id}
|
|
pictureId={item.picture_id}
|
|
url={displayUrl}
|
|
thumbnailUrl={item.thumbnail_url}
|
|
title={item.title}
|
|
// Pass blank/undefined so UserAvatarBlock uses context data
|
|
author={undefined as any}
|
|
authorAvatarUrl={undefined}
|
|
authorId={item.user_id}
|
|
likes={item.likes_count || 0}
|
|
comments={item.comments[0]?.count || 0}
|
|
isLiked={userLikes.has(item.picture_id || item.id)}
|
|
description={item.description}
|
|
type={itemType}
|
|
meta={item.meta}
|
|
onClick={() => handleMediaClick(item.id, itemType, index)}
|
|
onLike={fetchUserLikes}
|
|
onDelete={fetchMediaFromPicturesTable}
|
|
onEdit={handleEditPost}
|
|
created_at={item.created_at}
|
|
job={item.job}
|
|
responsive={item.responsive}
|
|
apiUrl={apiUrl}
|
|
preset={preset}
|
|
/>
|
|
<div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Maximize className="w-4 h-4 text-white" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MediaCard
|
|
key={item.id}
|
|
id={item.id}
|
|
pictureId={item.picture_id}
|
|
url={displayUrl}
|
|
thumbnailUrl={item.thumbnail_url}
|
|
title={item.title}
|
|
author={undefined as any}
|
|
authorAvatarUrl={undefined}
|
|
authorId={item.user_id}
|
|
likes={item.likes_count || 0}
|
|
comments={item?.comments?.[0]?.count || 0}
|
|
isLiked={userLikes.has(item.picture_id || item.id)}
|
|
description={item.description}
|
|
type={itemType}
|
|
meta={item.meta}
|
|
onClick={() => handleMediaClick(item.id, itemType, index)}
|
|
onLike={fetchUserLikes}
|
|
onDelete={fetchMediaFromPicturesTable}
|
|
onEdit={handleEditPost}
|
|
|
|
created_at={item.created_at}
|
|
job={item.job}
|
|
responsive={item.responsive}
|
|
apiUrl={apiUrl}
|
|
preset={preset}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* Loading Indicator / Observer Target */}
|
|
<div ref={observerTarget} className="h-10 w-full flex items-center justify-center p-4">
|
|
{isFetchingMore && (
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-white"></div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Backward compatibility export
|
|
export default MediaGrid;
|
|
|
|
// Named exports for clarity
|
|
export { MediaGrid };
|
|
export const PhotoGrid = MediaGrid; |