cat / home - widget props
This commit is contained in:
parent
9f5f7bf60d
commit
38d718b08c
@ -22,12 +22,13 @@ As part of its ongoing product roadmap, [Company Name] confirmed that PAC-BOT wi
|
||||
### Todos
|
||||
|
||||
- [x] gridsearch progress | pause | resume
|
||||
- [ ] settings : presets ( lang , overview, nearby, discover )
|
||||
- [ ] share => noFilters | columns | filters
|
||||
- [ ] types => partial / fuzzy match | post filters => import contacts
|
||||
- [ ] report => email
|
||||
- [ ] notifications => email
|
||||
- [ ] summary - business intelligence
|
||||
- [ ] report => email
|
||||
- [ ] settings : presets ( lang , overview, nearby, discover )
|
||||
|
||||
- [ ] share => noFilters | columns | filters
|
||||
- [ ] notifications => email
|
||||
- [ ] expand => easy => country | lang match
|
||||
- [ ] llm filters : places | areas
|
||||
- [ ] ui : track / trail / done zones
|
||||
|
||||
@ -1,364 +1,372 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useFeedData, FeedSortOption } from "@/hooks/useFeedData";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { MessageCircle, Heart, ExternalLink } from "lucide-react";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { T } from "@/i18n";
|
||||
const Post = React.lazy(() => import("@/modules/posts/PostPage"));
|
||||
const UserPage = React.lazy(() => import("@/modules/pages/UserPage"));
|
||||
|
||||
interface ListLayoutProps {
|
||||
sortBy?: FeedSortOption;
|
||||
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'search';
|
||||
navigationSourceId?: string;
|
||||
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
|
||||
categorySlugs?: string[];
|
||||
categoryIds?: string[];
|
||||
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
|
||||
visibilityFilter?: 'invisible' | 'private';
|
||||
center?: boolean;
|
||||
preset?: any;
|
||||
}
|
||||
|
||||
const ListItem = ({ item, isSelected, onClick, preset }: { item: any, isSelected: boolean, onClick: () => void, preset?: any }) => {
|
||||
const isExternal = item.type === 'page-external';
|
||||
const domain = isExternal && item.meta?.url ? new URL(item.meta.url).hostname : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 border-b flex items-start gap-3 cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? 'bg-muted border-l-4 border-l-primary' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{item.title || "Untitled"}
|
||||
</h3>
|
||||
{domain && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-0.5">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{domain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preset?.showDescription !== false && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-3 mb-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 min-w-0" onClick={(e) => { e.stopPropagation(); }}>
|
||||
<UserAvatarBlock
|
||||
userId={item.user_id || item.author?.id}
|
||||
avatarUrl={item.author?.avatar_url}
|
||||
displayName={item.author?.username}
|
||||
className="w-5 h-5"
|
||||
showDate={false}
|
||||
hoverStyle={false}
|
||||
textSize="xs"
|
||||
/>
|
||||
</div>
|
||||
<span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span>
|
||||
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{(item.likes_count || 0) >= 10 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3" />
|
||||
{item.likes_count}
|
||||
</span>
|
||||
)}
|
||||
{((item.comments && item.comments[0]?.count) || 0) >= 10 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
{(item.comments && item.comments[0]?.count) || 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail for quick context */}
|
||||
{/* Thumbnail for quick context */}
|
||||
<div className="w-16 h-16 shrink-0 rounded-md overflow-hidden bg-muted">
|
||||
{item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url ? (
|
||||
<img
|
||||
src={item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-current opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getSearchGroup = (post: any): string => {
|
||||
if (post.type === 'page-vfs-folder') return 'Folders';
|
||||
if (post._searchSource === 'picture') return 'Pictures';
|
||||
if (post._searchSource === 'place') return 'Places';
|
||||
if (post._searchSource === 'file') {
|
||||
if (post.thumbnail_url || post.cover || (post.pictures && post.pictures.length > 0)) return 'Pictures';
|
||||
return 'Files';
|
||||
}
|
||||
if (post._searchSource === 'page') return 'Pages';
|
||||
if (post._searchSource === 'post') return 'Posts';
|
||||
return 'Posts';
|
||||
};
|
||||
|
||||
export const ListLayout = ({
|
||||
sortBy = 'latest',
|
||||
navigationSource = 'home',
|
||||
navigationSourceId,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter,
|
||||
center,
|
||||
preset
|
||||
}: ListLayoutProps) => {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
// State for desktop selection
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
posts: feedPosts,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore
|
||||
} = useFeedData({
|
||||
source: navigationSource,
|
||||
sourceId: navigationSourceId,
|
||||
sortBy,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter
|
||||
});
|
||||
|
||||
const handleItemClick = (item: any) => {
|
||||
if (item.meta?.url) {
|
||||
navigate(item.meta.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
const slug = item.meta?.slug || item.cover?.meta?.slug || item.pictures?.[0]?.meta?.slug;
|
||||
if (item.type === 'page-intern' && slug) {
|
||||
const usernameOrId = item.author?.username || item.user_id;
|
||||
navigate(`/user/${usernameOrId}/pages/${slug}`);
|
||||
} else {
|
||||
navigate(`/post/${item.id}`);
|
||||
}
|
||||
} else {
|
||||
setSelectedId(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (isMobile || !selectedId) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const currentIndex = feedPosts.findIndex((p: any) => p.id === selectedId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let newIndex = currentIndex;
|
||||
if (e.key === 'ArrowDown' && currentIndex < feedPosts.length - 1) {
|
||||
newIndex = currentIndex + 1;
|
||||
} else if (e.key === 'ArrowUp' && currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
setSelectedId((feedPosts[newIndex] as any).id);
|
||||
// Ensure the list item is visible
|
||||
const element = document.getElementById(`list-item-${(feedPosts[newIndex] as any).id}`);
|
||||
// element?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, feedPosts, isMobile]);
|
||||
|
||||
// Select first item by default on desktop if nothing selected
|
||||
useEffect(() => {
|
||||
if (!isMobile && !selectedId && feedPosts.length > 0) {
|
||||
setSelectedId((feedPosts[0] as any).id);
|
||||
}
|
||||
}, [feedPosts, isMobile, selectedId]);
|
||||
|
||||
// Reset selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedId(null);
|
||||
}, [categorySlugs?.join(',')]);
|
||||
|
||||
if (loading && feedPosts.length === 0) {
|
||||
return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (feedPosts.length === 0) {
|
||||
return <div className="p-8 text-center text-muted-foreground">No posts found.</div>;
|
||||
}
|
||||
|
||||
const renderItems = (isMobileView: boolean) => {
|
||||
const shouldGroup = navigationSource === 'search' && (!contentType || contentType === 'files');
|
||||
|
||||
if (!shouldGroup) {
|
||||
return feedPosts.map((post: any) => (
|
||||
<div key={post.id} id={!isMobileView ? `list-item-${post.id}` : undefined}>
|
||||
<ListItem
|
||||
item={post}
|
||||
isSelected={!isMobileView && selectedId === post.id}
|
||||
onClick={() => handleItemClick(post)}
|
||||
preset={preset}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const post of feedPosts) {
|
||||
const group = getSearchGroup(post);
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(post);
|
||||
}
|
||||
|
||||
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (const group of orderedGroups) {
|
||||
if (groups.has(group)) {
|
||||
elements.push(
|
||||
<div key={`group-${group}`} className="px-3 py-1.5 bg-muted/40 border-y text-xs font-semibold uppercase tracking-wider text-muted-foreground sticky top-0 z-10 backdrop-blur-md">
|
||||
<T>{group}</T>
|
||||
</div>
|
||||
);
|
||||
elements.push(
|
||||
...groups.get(group)!.map((post: any) => (
|
||||
<div key={post.id} id={!isMobileView ? `list-item-${post.id}` : undefined}>
|
||||
<ListItem
|
||||
item={post}
|
||||
isSelected={!isMobileView && selectedId === post.id}
|
||||
onClick={() => handleItemClick(post)}
|
||||
preset={preset}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
// Desktop Split Layout
|
||||
return (
|
||||
<div className={`flex h-full overflow-hidden border rounded-lg shadow-sm dark:bg-slate-900/10 ${center ? 'max-w-7xl mx-auto' : ''}`}>
|
||||
{/* Left: List */}
|
||||
<div className="w-[350px] lg:w-[400px] border-r flex flex-col bg-card shrink-0">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-custom relative">
|
||||
{renderItems(false)}
|
||||
{hasMore && (
|
||||
<div className="p-4 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Detail */}
|
||||
<div className="flex-1 min-w-0 bg-background overflow-hidden relative flex flex-col h-[inherit]">
|
||||
{selectedId ? (
|
||||
(() => {
|
||||
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);
|
||||
if (!selectedPost) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
Select an item to view details
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const postAny = selectedPost as any;
|
||||
|
||||
// Check for slug in various locations depending on data structure
|
||||
const slug = postAny.meta?.slug || postAny.cover?.meta?.slug || postAny.pictures?.[0]?.meta?.slug;
|
||||
|
||||
if (postAny?.type === 'page-intern' && slug) {
|
||||
return (
|
||||
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<UserPage
|
||||
userId={postAny.user_id}
|
||||
slug={slug}
|
||||
embedded
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (postAny?.type === 'place-search' && postAny.meta?.url) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">{postAny.title}</h2>
|
||||
{postAny.description && (
|
||||
<p className="text-muted-foreground text-sm">{postAny.description}</p>
|
||||
)}
|
||||
<Link
|
||||
to={postAny.meta.url}
|
||||
className="text-primary font-medium hover:underline w-fit"
|
||||
>
|
||||
<T>Open place details</T>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<Post
|
||||
key={selectedId}
|
||||
postId={selectedId}
|
||||
embedded
|
||||
className="h-[inherit] overflow-y-auto scrollbar-custom"
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
Select an item to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile Layout
|
||||
return (
|
||||
<div className={`flex flex-col ${center ? 'max-w-3xl mx-auto' : ''}`}>
|
||||
{renderItems(true)}
|
||||
{hasMore && (
|
||||
<div className="p-4 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useFeedData, FeedSortOption } from "@/hooks/useFeedData";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { MessageCircle, Heart, ExternalLink } from "lucide-react";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { T } from "@/i18n";
|
||||
const Post = React.lazy(() => import("@/modules/posts/PostPage"));
|
||||
const UserPage = React.lazy(() => import("@/modules/pages/UserPage"));
|
||||
|
||||
interface ListLayoutProps {
|
||||
sortBy?: FeedSortOption;
|
||||
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'search';
|
||||
navigationSourceId?: string;
|
||||
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
|
||||
categorySlugs?: string[];
|
||||
categoryIds?: string[];
|
||||
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
|
||||
visibilityFilter?: 'invisible' | 'private';
|
||||
center?: boolean;
|
||||
preset?: any;
|
||||
}
|
||||
|
||||
const ListItem = ({ item, isSelected, onClick, preset }: { item: any, isSelected: boolean, onClick: () => void, preset?: any }) => {
|
||||
const isExternal = item.type === 'page-external';
|
||||
const domain = isExternal && item.meta?.url ? new URL(item.meta.url).hostname : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 border-b flex items-start gap-3 cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? 'bg-muted border-l-4 border-l-primary' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{item.title || "Untitled"}
|
||||
</h3>
|
||||
{domain && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-0.5">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{domain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preset?.showDescription !== false && item.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-3 mb-2">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(preset?.showAuthor !== false || preset?.showActions !== false) && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{preset?.showAuthor !== false && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 min-w-0" onClick={(e) => { e.stopPropagation(); }}>
|
||||
<UserAvatarBlock
|
||||
userId={item.user_id || item.author?.id}
|
||||
avatarUrl={item.author?.avatar_url}
|
||||
displayName={item.author?.username}
|
||||
className="w-5 h-5"
|
||||
showDate={false}
|
||||
hoverStyle={false}
|
||||
textSize="xs"
|
||||
/>
|
||||
</div>
|
||||
<span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preset?.showActions !== false && (
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{(item.likes_count || 0) >= 10 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3" />
|
||||
{item.likes_count}
|
||||
</span>
|
||||
)}
|
||||
{((item.comments && item.comments[0]?.count) || 0) >= 10 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
{(item.comments && item.comments[0]?.count) || 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail for quick context */}
|
||||
{/* Thumbnail for quick context */}
|
||||
<div className="w-16 h-16 shrink-0 rounded-md overflow-hidden bg-muted">
|
||||
{item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url ? (
|
||||
<img
|
||||
src={item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-current opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getSearchGroup = (post: any): string => {
|
||||
if (post.type === 'page-vfs-folder') return 'Folders';
|
||||
if (post._searchSource === 'picture') return 'Pictures';
|
||||
if (post._searchSource === 'place') return 'Places';
|
||||
if (post._searchSource === 'file') {
|
||||
if (post.thumbnail_url || post.cover || (post.pictures && post.pictures.length > 0)) return 'Pictures';
|
||||
return 'Files';
|
||||
}
|
||||
if (post._searchSource === 'page') return 'Pages';
|
||||
if (post._searchSource === 'post') return 'Posts';
|
||||
return 'Posts';
|
||||
};
|
||||
|
||||
export const ListLayout = ({
|
||||
sortBy = 'latest',
|
||||
navigationSource = 'home',
|
||||
navigationSourceId,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter,
|
||||
center,
|
||||
preset
|
||||
}: ListLayoutProps) => {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
// State for desktop selection
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
posts: feedPosts,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore
|
||||
} = useFeedData({
|
||||
source: navigationSource,
|
||||
sourceId: navigationSourceId,
|
||||
sortBy,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter
|
||||
});
|
||||
|
||||
const handleItemClick = (item: any) => {
|
||||
if (item.meta?.url) {
|
||||
navigate(item.meta.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
const slug = item.meta?.slug || item.cover?.meta?.slug || item.pictures?.[0]?.meta?.slug;
|
||||
if (item.type === 'page-intern' && slug) {
|
||||
const usernameOrId = item.author?.username || item.user_id;
|
||||
navigate(`/user/${usernameOrId}/pages/${slug}`);
|
||||
} else {
|
||||
navigate(`/post/${item.id}`);
|
||||
}
|
||||
} else {
|
||||
setSelectedId(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (isMobile || !selectedId) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const currentIndex = feedPosts.findIndex((p: any) => p.id === selectedId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let newIndex = currentIndex;
|
||||
if (e.key === 'ArrowDown' && currentIndex < feedPosts.length - 1) {
|
||||
newIndex = currentIndex + 1;
|
||||
} else if (e.key === 'ArrowUp' && currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
setSelectedId((feedPosts[newIndex] as any).id);
|
||||
// Ensure the list item is visible
|
||||
const element = document.getElementById(`list-item-${(feedPosts[newIndex] as any).id}`);
|
||||
// element?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedId, feedPosts, isMobile]);
|
||||
|
||||
// Select first item by default on desktop if nothing selected
|
||||
useEffect(() => {
|
||||
if (!isMobile && !selectedId && feedPosts.length > 0) {
|
||||
setSelectedId((feedPosts[0] as any).id);
|
||||
}
|
||||
}, [feedPosts, isMobile, selectedId]);
|
||||
|
||||
// Reset selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedId(null);
|
||||
}, [categorySlugs?.join(',')]);
|
||||
|
||||
if (loading && feedPosts.length === 0) {
|
||||
return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (feedPosts.length === 0) {
|
||||
return <div className="p-8 text-center text-muted-foreground">No posts found.</div>;
|
||||
}
|
||||
|
||||
const renderItems = (isMobileView: boolean) => {
|
||||
const shouldGroup = navigationSource === 'search' && (!contentType || contentType === 'files');
|
||||
|
||||
if (!shouldGroup) {
|
||||
return feedPosts.map((post: any) => (
|
||||
<div key={post.id} id={!isMobileView ? `list-item-${post.id}` : undefined}>
|
||||
<ListItem
|
||||
item={post}
|
||||
isSelected={!isMobileView && selectedId === post.id}
|
||||
onClick={() => handleItemClick(post)}
|
||||
preset={preset}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const post of feedPosts) {
|
||||
const group = getSearchGroup(post);
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(post);
|
||||
}
|
||||
|
||||
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (const group of orderedGroups) {
|
||||
if (groups.has(group)) {
|
||||
elements.push(
|
||||
<div key={`group-${group}`} className="px-3 py-1.5 bg-muted/40 border-y text-xs font-semibold uppercase tracking-wider text-muted-foreground sticky top-0 z-10 backdrop-blur-md">
|
||||
<T>{group}</T>
|
||||
</div>
|
||||
);
|
||||
elements.push(
|
||||
...groups.get(group)!.map((post: any) => (
|
||||
<div key={post.id} id={!isMobileView ? `list-item-${post.id}` : undefined}>
|
||||
<ListItem
|
||||
item={post}
|
||||
isSelected={!isMobileView && selectedId === post.id}
|
||||
onClick={() => handleItemClick(post)}
|
||||
preset={preset}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
// Desktop Split Layout
|
||||
return (
|
||||
<div className={`flex h-full overflow-hidden border rounded-lg shadow-sm dark:bg-slate-900/10 ${center ? 'max-w-7xl mx-auto' : ''}`}>
|
||||
{/* Left: List */}
|
||||
<div className="w-[350px] lg:w-[400px] border-r flex flex-col bg-card shrink-0">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-custom relative">
|
||||
{renderItems(false)}
|
||||
{hasMore && (
|
||||
<div className="p-4 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Detail */}
|
||||
<div className="flex-1 min-w-0 bg-background overflow-hidden relative flex flex-col h-[inherit]">
|
||||
{selectedId ? (
|
||||
(() => {
|
||||
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);
|
||||
if (!selectedPost) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
Select an item to view details
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const postAny = selectedPost as any;
|
||||
|
||||
// Check for slug in various locations depending on data structure
|
||||
const slug = postAny.meta?.slug || postAny.cover?.meta?.slug || postAny.pictures?.[0]?.meta?.slug;
|
||||
|
||||
if (postAny?.type === 'page-intern' && slug) {
|
||||
return (
|
||||
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<UserPage
|
||||
userId={postAny.user_id}
|
||||
slug={slug}
|
||||
embedded
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (postAny?.type === 'place-search' && postAny.meta?.url) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold">{postAny.title}</h2>
|
||||
{postAny.description && (
|
||||
<p className="text-muted-foreground text-sm">{postAny.description}</p>
|
||||
)}
|
||||
<Link
|
||||
to={postAny.meta.url}
|
||||
className="text-primary font-medium hover:underline w-fit"
|
||||
>
|
||||
<T>Open place details</T>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<Post
|
||||
key={selectedId}
|
||||
postId={selectedId}
|
||||
embedded
|
||||
className="h-[inherit] overflow-y-auto scrollbar-custom"
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
Select an item to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile Layout
|
||||
return (
|
||||
<div className={`flex flex-col ${center ? 'max-w-3xl mx-auto' : ''}`}>
|
||||
{renderItems(true)}
|
||||
{hasMore && (
|
||||
<div className="p-4 text-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,279 +1,280 @@
|
||||
/**
|
||||
* MediaCard - Unified component that renders the appropriate card type
|
||||
* based on the media type from the 'pictures' table
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PhotoCard from './PhotoCard';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
import PageCard from '@/modules/pages/PageCard';
|
||||
import type { CardPreset } from '@/modules/pages/PageCard';
|
||||
import { normalizeMediaType, MEDIA_TYPES, type MediaType } from '@/lib/mediaRegistry';
|
||||
import { getMimeCategory, CATEGORY_STYLE } from '@/modules/storage/helpers';
|
||||
import type { INode } from '@/modules/storage/types';
|
||||
import { MapPin } from 'lucide-react';
|
||||
|
||||
interface MediaCardProps {
|
||||
id: string;
|
||||
pictureId?: string; // Add pictureId explicitly
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
likes: number;
|
||||
comments: number;
|
||||
isLiked?: boolean;
|
||||
description?: string | null;
|
||||
type: MediaType;
|
||||
meta?: any;
|
||||
responsive?: any; // Keeping as any for now to avoid tight coupling or import ResponsiveData
|
||||
onClick?: (id: string) => void;
|
||||
onLike?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: (id: string) => void;
|
||||
created_at?: string;
|
||||
authorAvatarUrl?: string | null;
|
||||
showContent?: boolean;
|
||||
job?: any;
|
||||
variant?: 'grid' | 'feed';
|
||||
apiUrl?: string;
|
||||
versionCount?: number;
|
||||
preset?: CardPreset;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const MediaCard: React.FC<MediaCardProps> = ({
|
||||
id,
|
||||
pictureId,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
title,
|
||||
author,
|
||||
authorAvatarUrl,
|
||||
authorId,
|
||||
likes,
|
||||
comments,
|
||||
isLiked,
|
||||
description,
|
||||
type,
|
||||
meta,
|
||||
onClick,
|
||||
onLike,
|
||||
onDelete,
|
||||
onEdit,
|
||||
created_at,
|
||||
showContent = true,
|
||||
responsive,
|
||||
job,
|
||||
variant = 'grid',
|
||||
apiUrl,
|
||||
versionCount,
|
||||
preset,
|
||||
showTitle,
|
||||
showDescription,
|
||||
showAuthor,
|
||||
showActions
|
||||
}) => {
|
||||
const normalizedType = normalizeMediaType(type);
|
||||
// Render based on type
|
||||
if (normalizedType === 'tiktok') {
|
||||
return (
|
||||
<div className="w-full h-full bg-black flex justify-center aspect-[9/16] rounded-md overflow-hidden shadow-sm border relative">
|
||||
<iframe
|
||||
src={url}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedType === MEDIA_TYPES.VIDEO_INTERN || normalizedType === MEDIA_TYPES.VIDEO_EXTERN) {
|
||||
return (
|
||||
<VideoCard
|
||||
videoId={pictureId || id}
|
||||
videoUrl={url}
|
||||
thumbnailUrl={thumbnailUrl || undefined}
|
||||
title={title}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
likes={likes}
|
||||
comments={comments}
|
||||
isLiked={isLiked}
|
||||
description={description}
|
||||
onClick={() => onClick?.(id)}
|
||||
onLike={onLike}
|
||||
onDelete={onDelete}
|
||||
showContent={showContent}
|
||||
created_at={created_at}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
job={job}
|
||||
variant={variant}
|
||||
apiUrl={apiUrl}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showActions={showActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (normalizedType === 'page-vfs-file' || normalizedType === 'page-vfs-folder') {
|
||||
// If we have a thumbnail_url mapped by the feed, that means client-posts resolved a valid cover.
|
||||
// Let PageCard render it normally as an image.
|
||||
if (!thumbnailUrl) {
|
||||
const isFolder = normalizedType === 'page-vfs-folder';
|
||||
const mockNode: INode = { name: title, mime: isFolder ? 'inode/directory' : '', type: isFolder ? 'dir' : 'file', path: '', size: 0, parent: '' } as any;
|
||||
const category = getMimeCategory(mockNode);
|
||||
const style = CATEGORY_STYLE[category] || CATEGORY_STYLE.other;
|
||||
const Icon = style.icon;
|
||||
|
||||
if (variant === 'feed') {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(id)}
|
||||
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full mb-4 border rounded-lg hover:border-primary/50"
|
||||
>
|
||||
<div className="w-full aspect-video bg-muted/20 flex flex-col items-center justify-center gap-4">
|
||||
<Icon className="w-24 h-24" style={{ color: style.color }} />
|
||||
<span className="text-lg font-medium text-muted-foreground truncate max-w-[80%] px-4">{title}</span>
|
||||
</div>
|
||||
{showContent && (
|
||||
<div className="p-4 space-y-2 border-t">
|
||||
<div className="font-semibold text-base truncate flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 flex-shrink-0" style={{ color: style.color }} />
|
||||
<span className="truncate" title={title}>{title}</span>
|
||||
</div>
|
||||
{description && <div className="text-sm text-muted-foreground truncate" title={description}>{description}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(id)}
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${preset?.showTitle ? '' : 'md:aspect-square'} flex flex-col border rounded-lg hover:border-primary/50`}
|
||||
>
|
||||
<div className="flex-1 w-full aspect-square md:aspect-auto flex items-center justify-center bg-muted/20 relative">
|
||||
<Icon className="w-16 h-16 transition-transform duration-300 group-hover:scale-110" style={{ color: style.color }} />
|
||||
</div>
|
||||
{(preset?.showTitle !== false || preset?.showDescription !== false) && (
|
||||
<div className="px-3 py-2 border-t bg-muted/40 absolute bottom-0 left-0 right-0 md:relative bg-background/95 backdrop-blur-sm md:bg-muted/40 md:backdrop-blur-none transition-transform pointer-events-none">
|
||||
{preset?.showTitle !== false && title && (
|
||||
<h3 className="text-sm font-medium truncate flex items-center gap-1.5" title={title}>
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
)}
|
||||
{preset?.showDescription !== false && description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedType === MEDIA_TYPES.PLACE_SEARCH) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(id)}
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${preset?.showTitle ? '' : 'md:aspect-square'} flex flex-col border rounded-lg hover:border-primary/50`}
|
||||
>
|
||||
<div className="flex-1 w-full aspect-square md:aspect-auto flex items-center justify-center bg-muted/20 relative">
|
||||
<MapPin className="w-16 h-16 text-primary transition-transform duration-300 group-hover:scale-110" />
|
||||
</div>
|
||||
{(preset?.showTitle !== false || preset?.showDescription !== false) && (
|
||||
<div className="px-3 py-2 border-t bg-muted/40 absolute bottom-0 left-0 right-0 md:relative bg-background/95 backdrop-blur-sm md:bg-muted/40 md:backdrop-blur-none transition-transform pointer-events-none">
|
||||
{preset?.showTitle !== false && title && (
|
||||
<h3 className="text-sm font-medium truncate flex items-center gap-1.5" title={title}>
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
)}
|
||||
{meta?.placeType && (
|
||||
<p className="text-xs text-muted-foreground/90 truncate mt-0.5">{meta.placeType}</p>
|
||||
)}
|
||||
{preset?.showDescription !== false && description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedType === MEDIA_TYPES.PAGE ||
|
||||
normalizedType === MEDIA_TYPES.PAGE_EXTERNAL ||
|
||||
normalizedType === 'page-vfs-file' ||
|
||||
normalizedType === 'page-vfs-folder') {
|
||||
return (
|
||||
<PageCard
|
||||
id={id}
|
||||
url={url} // For external pages, this is the link
|
||||
thumbnailUrl={thumbnailUrl} // Preview image
|
||||
title={title}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
likes={likes}
|
||||
comments={comments}
|
||||
isLiked={isLiked}
|
||||
description={description}
|
||||
type={type}
|
||||
meta={meta}
|
||||
onClick={() => onClick?.(id)}
|
||||
onLike={onLike}
|
||||
onDelete={onDelete}
|
||||
showContent={showContent}
|
||||
created_at={created_at}
|
||||
responsive={responsive}
|
||||
variant={variant}
|
||||
apiUrl={apiUrl}
|
||||
preset={preset}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showActions={showActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to PhotoCard for images (type === null or 'supabase-image')
|
||||
return (
|
||||
<PhotoCard
|
||||
pictureId={pictureId || id} // Use pictureId if available
|
||||
image={url}
|
||||
title={title}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
likes={likes}
|
||||
comments={comments}
|
||||
isLiked={isLiked}
|
||||
description={description}
|
||||
onClick={onClick}
|
||||
onLike={onLike}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
createdAt={created_at}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
showContent={showContent}
|
||||
responsive={responsive}
|
||||
variant={variant}
|
||||
apiUrl={apiUrl}
|
||||
versionCount={versionCount}
|
||||
preset={preset}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showActions={showActions}
|
||||
placeTypeLabel={meta?.placeType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCard;
|
||||
/**
|
||||
* MediaCard - Unified component that renders the appropriate card type
|
||||
* based on the media type from the 'pictures' table
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PhotoCard from './PhotoCard';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
import PageCard from '@/modules/pages/PageCard';
|
||||
import type { CardPreset } from '@/modules/pages/PageCard';
|
||||
import { normalizeMediaType, MEDIA_TYPES, type MediaType } from '@/lib/mediaRegistry';
|
||||
import { getMimeCategory, CATEGORY_STYLE } from '@/modules/storage/helpers';
|
||||
import type { INode } from '@/modules/storage/types';
|
||||
import { MapPin } from 'lucide-react';
|
||||
|
||||
interface MediaCardProps {
|
||||
id: string;
|
||||
pictureId?: string; // Add pictureId explicitly
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
likes: number;
|
||||
comments: number;
|
||||
isLiked?: boolean;
|
||||
description?: string | null;
|
||||
type: MediaType;
|
||||
meta?: any;
|
||||
responsive?: any; // Keeping as any for now to avoid tight coupling or import ResponsiveData
|
||||
onClick?: (id: string) => void;
|
||||
onLike?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: (id: string) => void;
|
||||
created_at?: string;
|
||||
authorAvatarUrl?: string | null;
|
||||
showContent?: boolean;
|
||||
job?: any;
|
||||
variant?: 'grid' | 'feed';
|
||||
apiUrl?: string;
|
||||
versionCount?: number;
|
||||
preset?: CardPreset;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const MediaCard: React.FC<MediaCardProps> = ({
|
||||
id,
|
||||
pictureId,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
title,
|
||||
author,
|
||||
authorAvatarUrl,
|
||||
authorId,
|
||||
likes,
|
||||
comments,
|
||||
isLiked,
|
||||
description,
|
||||
type,
|
||||
meta,
|
||||
onClick,
|
||||
onLike,
|
||||
onDelete,
|
||||
onEdit,
|
||||
created_at,
|
||||
showContent = true,
|
||||
responsive,
|
||||
job,
|
||||
variant = 'grid',
|
||||
apiUrl,
|
||||
versionCount,
|
||||
preset,
|
||||
showTitle,
|
||||
showDescription,
|
||||
showAuthor,
|
||||
showActions
|
||||
}) => {
|
||||
const normalizedType = normalizeMediaType(type);
|
||||
// Render based on type
|
||||
if (normalizedType === 'tiktok') {
|
||||
return (
|
||||
<div className="w-full h-full bg-black flex justify-center aspect-[9/16] rounded-md overflow-hidden shadow-sm border relative">
|
||||
<iframe
|
||||
src={url}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedType === MEDIA_TYPES.VIDEO_INTERN || normalizedType === MEDIA_TYPES.VIDEO_EXTERN) {
|
||||
return (
|
||||
<VideoCard
|
||||
videoId={pictureId || id}
|
||||
videoUrl={url}
|
||||
thumbnailUrl={thumbnailUrl || undefined}
|
||||
title={title}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
likes={likes}
|
||||
comments={comments}
|
||||
isLiked={isLiked}
|
||||
description={description}
|
||||
onClick={() => onClick?.(id)}
|
||||
onLike={onLike}
|
||||
onDelete={onDelete}
|
||||
showContent={showContent}
|
||||
created_at={created_at}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
job={job}
|
||||
variant={variant}
|
||||
apiUrl={apiUrl}
|
||||
preset={preset}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showActions={showActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (normalizedType === 'page-vfs-file' || normalizedType === 'page-vfs-folder') {
|
||||
// If we have a thumbnail_url mapped by the feed, that means client-posts resolved a valid cover.
|
||||
// Let PageCard render it normally as an image.
|
||||
if (!thumbnailUrl) {
|
||||
const isFolder = normalizedType === 'page-vfs-folder';
|
||||
const mockNode: INode = { name: title, mime: isFolder ? 'inode/directory' : '', type: isFolder ? 'dir' : 'file', path: '', size: 0, parent: '' } as any;
|
||||
const category = getMimeCategory(mockNode);
|
||||
const style = CATEGORY_STYLE[category] || CATEGORY_STYLE.other;
|
||||
const Icon = style.icon;
|
||||
|
||||
if (variant === 'feed') {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(id)}
|
||||
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full mb-4 border rounded-lg hover:border-primary/50"
|
||||
>
|
||||
<div className="w-full aspect-video bg-muted/20 flex flex-col items-center justify-center gap-4">
|
||||
<Icon className="w-24 h-24" style={{ color: style.color }} />
|
||||
<span className="text-lg font-medium text-muted-foreground truncate max-w-[80%] px-4">{title}</span>
|
||||
</div>
|
||||
{showContent && (
|
||||
<div className="p-4 space-y-2 border-t">
|
||||
<div className="font-semibold text-base truncate flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 flex-shrink-0" style={{ color: style.color }} />
|
||||
<span className="truncate" title={title}>{title}</span>
|
||||
</div>
|
||||
{description && <div className="text-sm text-muted-foreground truncate" title={description}>{description}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(id)}
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${preset?.showTitle ? '' : 'md:aspect-square'} flex flex-col border rounded-lg hover:border-primary/50`}
|
||||
>
|
||||
<div className="flex-1 w-full aspect-square md:aspect-auto flex items-center justify-center bg-muted/20 relative">
|
||||
<Icon className="w-16 h-16 transition-transform duration-300 group-hover:scale-110" style={{ color: style.color }} />
|
||||
</div>
|
||||
{(preset?.showTitle !== false || preset?.showDescription !== false) && (
|
||||
<div className="px-3 py-2 border-t bg-muted/40 absolute bottom-0 left-0 right-0 md:relative bg-background/95 backdrop-blur-sm md:bg-muted/40 md:backdrop-blur-none transition-transform pointer-events-none">
|
||||
{preset?.showTitle !== false && title && (
|
||||
<h3 className="text-sm font-medium truncate flex items-center gap-1.5" title={title}>
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
)}
|
||||
{preset?.showDescription !== false && description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedType === MEDIA_TYPES.PLACE_SEARCH) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(id)}
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${preset?.showTitle ? '' : 'md:aspect-square'} flex flex-col border rounded-lg hover:border-primary/50`}
|
||||
>
|
||||
<div className="flex-1 w-full aspect-square md:aspect-auto flex items-center justify-center bg-muted/20 relative">
|
||||
<MapPin className="w-16 h-16 text-primary transition-transform duration-300 group-hover:scale-110" />
|
||||
</div>
|
||||
{(preset?.showTitle !== false || preset?.showDescription !== false) && (
|
||||
<div className="px-3 py-2 border-t bg-muted/40 absolute bottom-0 left-0 right-0 md:relative bg-background/95 backdrop-blur-sm md:bg-muted/40 md:backdrop-blur-none transition-transform pointer-events-none">
|
||||
{preset?.showTitle !== false && title && (
|
||||
<h3 className="text-sm font-medium truncate flex items-center gap-1.5" title={title}>
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
)}
|
||||
{meta?.placeType && (
|
||||
<p className="text-xs text-muted-foreground/90 truncate mt-0.5">{meta.placeType}</p>
|
||||
)}
|
||||
{preset?.showDescription !== false && description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedType === MEDIA_TYPES.PAGE ||
|
||||
normalizedType === MEDIA_TYPES.PAGE_EXTERNAL ||
|
||||
normalizedType === 'page-vfs-file' ||
|
||||
normalizedType === 'page-vfs-folder') {
|
||||
return (
|
||||
<PageCard
|
||||
id={id}
|
||||
url={url} // For external pages, this is the link
|
||||
thumbnailUrl={thumbnailUrl} // Preview image
|
||||
title={title}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
likes={likes}
|
||||
comments={comments}
|
||||
isLiked={isLiked}
|
||||
description={description}
|
||||
type={type}
|
||||
meta={meta}
|
||||
onClick={() => onClick?.(id)}
|
||||
onLike={onLike}
|
||||
onDelete={onDelete}
|
||||
showContent={showContent}
|
||||
created_at={created_at}
|
||||
responsive={responsive}
|
||||
variant={variant}
|
||||
apiUrl={apiUrl}
|
||||
preset={preset}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showActions={showActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to PhotoCard for images (type === null or 'supabase-image')
|
||||
return (
|
||||
<PhotoCard
|
||||
pictureId={pictureId || id} // Use pictureId if available
|
||||
image={url}
|
||||
title={title}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
likes={likes}
|
||||
comments={comments}
|
||||
isLiked={isLiked}
|
||||
description={description}
|
||||
onClick={onClick}
|
||||
onLike={onLike}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
createdAt={created_at}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
showContent={showContent}
|
||||
responsive={responsive}
|
||||
variant={variant}
|
||||
apiUrl={apiUrl}
|
||||
versionCount={versionCount}
|
||||
preset={preset}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showActions={showActions}
|
||||
placeTypeLabel={meta?.placeType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaCard;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,186 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FeedPost } from '@/modules/posts/client-posts';
|
||||
import { FeedCarousel } from './FeedCarousel';
|
||||
import { Heart, MessageCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { normalizeMediaType } from "@/lib/mediaRegistry";
|
||||
import { toggleLike } from '@/modules/posts/client-pictures';
|
||||
import UserAvatarBlock from '@/components/UserAvatarBlock';
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||||
import { formatDate } from '@/utils/textUtils';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
interface FeedCardProps {
|
||||
post: FeedPost;
|
||||
currentUserId?: string;
|
||||
onLike?: () => void;
|
||||
onComment?: () => void;
|
||||
onShare?: () => void;
|
||||
onNavigate?: (id: string) => void;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
export const FeedCard: React.FC<FeedCardProps> = ({
|
||||
post,
|
||||
currentUserId,
|
||||
onLike,
|
||||
onNavigate,
|
||||
showTitle = false,
|
||||
showDescription = false,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
// Initialize from precomputed status (post.cover.is_liked or post.is_liked)
|
||||
// We prioritize cover.is_liked if available, matching the server logic
|
||||
const initialLiked = post.cover?.is_liked ?? post.is_liked ?? false;
|
||||
const [isLiked, setIsLiked] = useState<boolean>(initialLiked);
|
||||
const [likeCount, setLikeCount] = useState(post.likes_count || 0);
|
||||
const [lastTap, setLastTap] = useState<number>(0);
|
||||
const [showHeartAnimation, setShowHeartAnimation] = useState(false);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!currentUserId || !post.cover?.id) return;
|
||||
|
||||
// Optimistic update
|
||||
const newStatus = !isLiked;
|
||||
setIsLiked(newStatus);
|
||||
setLikeCount(prev => newStatus ? prev + 1 : prev - 1);
|
||||
|
||||
try {
|
||||
await toggleLike(currentUserId, post.cover.id, isLiked);
|
||||
onLike?.();
|
||||
} catch (e) {
|
||||
// Revert
|
||||
setIsLiked(!newStatus);
|
||||
setLikeCount(prev => !newStatus ? prev + 1 : prev - 1);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleTap = (e: React.SyntheticEvent) => {
|
||||
const now = Date.now();
|
||||
const DOUBLE_TAP_DELAY = 300;
|
||||
|
||||
if (now - lastTap < DOUBLE_TAP_DELAY) {
|
||||
if (!isLiked) {
|
||||
handleLike();
|
||||
}
|
||||
setShowHeartAnimation(true);
|
||||
setTimeout(() => setShowHeartAnimation(false), 1000);
|
||||
}
|
||||
setLastTap(now);
|
||||
};
|
||||
|
||||
// Prepare items for carousel
|
||||
const carouselItems = (post.pictures && post.pictures.length > 0
|
||||
? post.pictures
|
||||
: [post.cover]).filter(item => !!item);
|
||||
|
||||
const handleItemClick = (itemId: string) => {
|
||||
const item = carouselItems.find(i => i.id === itemId);
|
||||
if (item) {
|
||||
const type = normalizeMediaType(item.type);
|
||||
if (type === 'page-intern' && item.meta?.slug) {
|
||||
const username = post.author?.username;
|
||||
navigate(`/user/${username || item.user_id || post.user_id}/pages/${item.meta.slug}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onNavigate?.(post.id);
|
||||
};
|
||||
|
||||
if (carouselItems.length === 0) {
|
||||
if (post.type === 'page-vfs-file' || post.type === 'page-vfs-folder' || post.type === 'page-intern' || post.type === 'page-external' || post.type === 'place-search') {
|
||||
return (
|
||||
<article className="bg-background md:border md:mb-6 md:pb-0 overflow-hidden p-4">
|
||||
<div onClick={() => {
|
||||
if (post.meta?.url) navigate(post.meta.url);
|
||||
else if (post.type === 'page-intern' && post.meta?.slug) navigate(`/user/${post.author?.username || post.user_id}/pages/${post.meta.slug}`);
|
||||
else onNavigate?.(post.id);
|
||||
}} className="cursor-pointer">
|
||||
<h3 className="font-semibold text-lg">{post.title}</h3>
|
||||
{post.description && <p className="text-muted-foreground text-sm mt-1">{post.description}</p>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="bg-background md:border md:mb-6 md:pb-0 overflow-hidden">
|
||||
{/* Header: Author Info */}
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<UserAvatarBlock
|
||||
userId={post.user_id}
|
||||
avatarUrl={post.author?.avatar_url}
|
||||
displayName={post.author?.display_name || post.author?.username}
|
||||
showDate={false}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{post.created_at ? formatDate(post.created_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Carousel */}
|
||||
<div className="relative" onTouchEnd={handleDoubleTap} onClick={handleDoubleTap}>
|
||||
<FeedCarousel
|
||||
items={carouselItems}
|
||||
aspectRatio={1}
|
||||
className="w-full bg-muted"
|
||||
author={post.author?.display_name || post.author?.username || 'User'}
|
||||
authorId={post.user_id}
|
||||
authorAvatarUrl={post.author?.avatar_url}
|
||||
onItemClick={handleItemClick}
|
||||
showContent={false} // Silence internal card UI
|
||||
showAuthor={false}
|
||||
showActions={false}
|
||||
showTitle={false}
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="flex items-center px-2 pt-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleLike}
|
||||
className={cn("hover:text-red-500", isLiked && "text-red-500")}
|
||||
>
|
||||
<Heart className="h-6 w-6" fill={isLiked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
{likeCount > 0 && (
|
||||
<span className="text-sm font-medium mr-1">{likeCount}</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-foreground"
|
||||
onClick={() => {
|
||||
// Pass ID to parent but if it's not implemented, navigate to details
|
||||
onNavigate?.(post.id);
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="h-6 w-6 -rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Caption: Title & Description */}
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{showTitle && post.title && (
|
||||
<div className="font-bold text-sm">{post.title}</div>
|
||||
)}
|
||||
{showDescription && post.description && (
|
||||
<div className="text-sm text-foreground/90 line-clamp-3">
|
||||
<MarkdownRenderer content={post.description} className="prose-sm dark:prose-invert" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
import React, { useState } from 'react';
|
||||
import { FeedPost } from '@/modules/posts/client-posts';
|
||||
import { FeedCarousel } from './FeedCarousel';
|
||||
import { Heart, MessageCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { normalizeMediaType } from "@/lib/mediaRegistry";
|
||||
import { toggleLike } from '@/modules/posts/client-pictures';
|
||||
import UserAvatarBlock from '@/components/UserAvatarBlock';
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||||
import { formatDate } from '@/utils/textUtils';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
interface FeedCardProps {
|
||||
post: FeedPost;
|
||||
currentUserId?: string;
|
||||
onLike?: () => void;
|
||||
onComment?: () => void;
|
||||
onShare?: () => void;
|
||||
onNavigate?: (id: string) => void;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showSocial?: boolean;
|
||||
}
|
||||
|
||||
export const FeedCard: React.FC<FeedCardProps> = ({
|
||||
post,
|
||||
currentUserId,
|
||||
onLike,
|
||||
onNavigate,
|
||||
showTitle = false,
|
||||
showDescription = false,
|
||||
showAuthor = true,
|
||||
showSocial = true,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
// Initialize from precomputed status (post.cover.is_liked or post.is_liked)
|
||||
// We prioritize cover.is_liked if available, matching the server logic
|
||||
const initialLiked = post.cover?.is_liked ?? post.is_liked ?? false;
|
||||
const [isLiked, setIsLiked] = useState<boolean>(initialLiked);
|
||||
const [likeCount, setLikeCount] = useState(post.likes_count || 0);
|
||||
const [lastTap, setLastTap] = useState<number>(0);
|
||||
const [showHeartAnimation, setShowHeartAnimation] = useState(false);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!currentUserId || !post.cover?.id) return;
|
||||
|
||||
// Optimistic update
|
||||
const newStatus = !isLiked;
|
||||
setIsLiked(newStatus);
|
||||
setLikeCount(prev => newStatus ? prev + 1 : prev - 1);
|
||||
|
||||
try {
|
||||
await toggleLike(currentUserId, post.cover.id, isLiked);
|
||||
onLike?.();
|
||||
} catch (e) {
|
||||
// Revert
|
||||
setIsLiked(!newStatus);
|
||||
setLikeCount(prev => !newStatus ? prev + 1 : prev - 1);
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleTap = (e: React.SyntheticEvent) => {
|
||||
const now = Date.now();
|
||||
const DOUBLE_TAP_DELAY = 300;
|
||||
|
||||
if (now - lastTap < DOUBLE_TAP_DELAY) {
|
||||
if (!isLiked) {
|
||||
handleLike();
|
||||
}
|
||||
setShowHeartAnimation(true);
|
||||
setTimeout(() => setShowHeartAnimation(false), 1000);
|
||||
}
|
||||
setLastTap(now);
|
||||
};
|
||||
|
||||
// Prepare items for carousel
|
||||
const carouselItems = (post.pictures && post.pictures.length > 0
|
||||
? post.pictures
|
||||
: [post.cover]).filter(item => !!item);
|
||||
|
||||
const handleItemClick = (itemId: string) => {
|
||||
const item = carouselItems.find(i => i.id === itemId);
|
||||
if (item) {
|
||||
const type = normalizeMediaType(item.type);
|
||||
if (type === 'page-intern' && item.meta?.slug) {
|
||||
const username = post.author?.username;
|
||||
navigate(`/user/${username || item.user_id || post.user_id}/pages/${item.meta.slug}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onNavigate?.(post.id);
|
||||
};
|
||||
|
||||
if (carouselItems.length === 0) {
|
||||
if (post.type === 'page-vfs-file' || post.type === 'page-vfs-folder' || post.type === 'page-intern' || post.type === 'page-external' || post.type === 'place-search') {
|
||||
return (
|
||||
<article className="bg-background md:border md:mb-6 md:pb-0 overflow-hidden p-4">
|
||||
<div onClick={() => {
|
||||
if (post.meta?.url) navigate(post.meta.url);
|
||||
else if (post.type === 'page-intern' && post.meta?.slug) navigate(`/user/${post.author?.username || post.user_id}/pages/${post.meta.slug}`);
|
||||
else onNavigate?.(post.id);
|
||||
}} className="cursor-pointer">
|
||||
<h3 className="font-semibold text-lg">{post.title}</h3>
|
||||
{post.description && <p className="text-muted-foreground text-sm mt-1">{post.description}</p>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="bg-background md:border md:mb-6 md:pb-0 overflow-hidden">
|
||||
{/* Header: Author Info */}
|
||||
{showAuthor && (
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<UserAvatarBlock
|
||||
userId={post.user_id}
|
||||
avatarUrl={post.author?.avatar_url}
|
||||
displayName={post.author?.display_name || post.author?.username}
|
||||
showDate={false}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{post.created_at ? formatDate(post.created_at) : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media Carousel */}
|
||||
<div className="relative" onTouchEnd={handleDoubleTap} onClick={handleDoubleTap}>
|
||||
<FeedCarousel
|
||||
items={carouselItems}
|
||||
aspectRatio={1}
|
||||
className="w-full bg-muted"
|
||||
author={post.author?.display_name || post.author?.username || 'User'}
|
||||
authorId={post.user_id}
|
||||
authorAvatarUrl={post.author?.avatar_url}
|
||||
onItemClick={handleItemClick}
|
||||
showContent={false} // Silence internal card UI
|
||||
showAuthor={false}
|
||||
showActions={false}
|
||||
showTitle={false}
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions Bar */}
|
||||
{showSocial && (
|
||||
<div className="flex items-center px-2 pt-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleLike}
|
||||
className={cn("hover:text-red-500", isLiked && "text-red-500")}
|
||||
>
|
||||
<Heart className="h-6 w-6" fill={isLiked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
{likeCount > 0 && (
|
||||
<span className="text-sm font-medium mr-1">{likeCount}</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-foreground"
|
||||
onClick={() => {
|
||||
onNavigate?.(post.id);
|
||||
}}
|
||||
>
|
||||
<MessageCircle className="h-6 w-6 -rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption: Title & Description */}
|
||||
<div className={cn('px-4 pb-3 space-y-1', !showSocial && 'pt-2')}>
|
||||
{showTitle && post.title && (
|
||||
<div className="font-bold text-sm">{post.title}</div>
|
||||
)}
|
||||
{showDescription && post.description && (
|
||||
<div className="text-sm text-foreground/90 line-clamp-3">
|
||||
<MarkdownRenderer content={post.description} className="prose-sm dark:prose-invert" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,237 +1,249 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FeedCard } from './FeedCard';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useFeedData, FeedSortOption } from '@/hooks/useFeedData';
|
||||
import { useFeedCache } from '@/contexts/FeedCacheContext';
|
||||
import { useOrganization } from '@/contexts/OrganizationContext';
|
||||
import { FeedPost } from '@/modules/posts/client-posts';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
interface MobileFeedProps {
|
||||
source?: 'home' | 'collection' | 'tag' | 'user' | 'search';
|
||||
sourceId?: string;
|
||||
onNavigate?: (id: string) => void;
|
||||
sortBy?: FeedSortOption;
|
||||
categorySlugs?: string[];
|
||||
categoryIds?: string[];
|
||||
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
|
||||
visibilityFilter?: 'invisible' | 'private';
|
||||
center?: boolean;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
export const MobileFeed: React.FC<MobileFeedProps> = ({
|
||||
source = 'home',
|
||||
sourceId,
|
||||
onNavigate,
|
||||
sortBy = 'latest',
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter,
|
||||
center,
|
||||
showTitle,
|
||||
showDescription,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { getCache, saveCache } = useFeedCache();
|
||||
const { orgSlug, isOrgContext } = useOrganization();
|
||||
|
||||
// Use centralized feed hook
|
||||
const { posts, loading, error, hasMore } = useFeedData({
|
||||
source,
|
||||
sourceId,
|
||||
sortBy,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter
|
||||
});
|
||||
|
||||
// Scroll Restoration Logic
|
||||
const cacheKey = `${source}-${sourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
|
||||
const hasRestoredScroll = useRef(false);
|
||||
const lastScrollY = useRef(window.scrollY);
|
||||
|
||||
// Track scroll position
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
lastScrollY.current = window.scrollY;
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Restore scroll when posts are populated
|
||||
React.useLayoutEffect(() => {
|
||||
if (posts.length > 0 && !hasRestoredScroll.current) {
|
||||
const cached = getCache(cacheKey);
|
||||
if (cached && cached.scrollY > 0) {
|
||||
window.scrollTo(0, cached.scrollY);
|
||||
}
|
||||
hasRestoredScroll.current = true;
|
||||
}
|
||||
}, [posts, cacheKey, getCache]);
|
||||
|
||||
// Save scroll position on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// We need to save the current state to cache
|
||||
// We can't easily get the *current* state of 'posts' inside this cleanup unless we use a ref or rely on the hook's cache
|
||||
// Actually, useFeedData already saves the data state. We just need to ensure scrollY is updated.
|
||||
// But useFeedData saves on render/update.
|
||||
// Let's simply update the existing cache entry with the new scrollY
|
||||
const currentCache = getCache(cacheKey);
|
||||
if (currentCache && lastScrollY.current > 0) {
|
||||
saveCache(cacheKey, { ...currentCache, scrollY: lastScrollY.current });
|
||||
}
|
||||
};
|
||||
}, [cacheKey, getCache, saveCache]);
|
||||
|
||||
|
||||
// Preloading Logic
|
||||
// We simply use <link rel="preload"> or simple JS Image object creation
|
||||
// But efficiently we want to do it based on scroll position.
|
||||
|
||||
// Simplest robust "Load 5 ahead" implementation:
|
||||
// We already have the URLs in `posts`.
|
||||
// We can observe the visible index and preload index + 1 to index + 5.
|
||||
|
||||
// Intersection Observer for Virtual Window / Preload trigger
|
||||
// Since we don't have a truly huge list (yet), we will render the list
|
||||
// but attach an observer to items to trigger preloading of subsequent items.
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-center text-red-500">
|
||||
Failed to load feed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>No posts yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSearchTabAllOrFiles = source === 'search' && (!contentType || contentType === 'files');
|
||||
|
||||
const renderItems = () => {
|
||||
if (!isSearchTabAllOrFiles) {
|
||||
return posts.map((post, index) => (
|
||||
<FeedItemWrapper
|
||||
key={post.id}
|
||||
post={post}
|
||||
index={index}
|
||||
posts={posts}
|
||||
currentUser={user}
|
||||
onNavigate={onNavigate}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const getSearchGroup = (item: any): string => {
|
||||
if (item.type === 'page-vfs-folder') return 'Folders';
|
||||
if (item._searchSource === 'picture') return 'Pictures';
|
||||
if (item._searchSource === 'place') return 'Places';
|
||||
if (item._searchSource === 'file') {
|
||||
if (item.thumbnail_url || item.cover || (item.pictures && item.pictures.length > 0)) return 'Pictures';
|
||||
return 'Files';
|
||||
}
|
||||
if (item._searchSource === 'page') return 'Pages';
|
||||
if (item._searchSource === 'post') return 'Posts';
|
||||
return 'Posts';
|
||||
};
|
||||
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const post of posts) {
|
||||
const group = getSearchGroup(post);
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(post);
|
||||
}
|
||||
|
||||
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (const group of orderedGroups) {
|
||||
if (groups.has(group)) {
|
||||
elements.push(
|
||||
<div key={`group-${group}`} className="px-3 py-1.5 bg-muted/40 border-y text-xs font-semibold uppercase tracking-wider text-muted-foreground sticky top-0 z-10 backdrop-blur-md">
|
||||
<T>{group}</T>
|
||||
</div>
|
||||
);
|
||||
elements.push(
|
||||
...groups.get(group)!.map((post: any, index: number) => (
|
||||
<FeedItemWrapper
|
||||
key={post.id}
|
||||
post={post}
|
||||
index={index}
|
||||
posts={posts}
|
||||
currentUser={user}
|
||||
onNavigate={onNavigate}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full ${center ? 'max-w-3xl mx-auto' : ''}`}>
|
||||
{renderItems()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper directly handles visibility logic to trigger preloads
|
||||
const FeedItemWrapper: React.FC<{
|
||||
post: FeedPost,
|
||||
index: number,
|
||||
posts: FeedPost[],
|
||||
currentUser: any,
|
||||
onNavigate?: (id: string) => void;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
}> = ({ post, index, posts, currentUser, onNavigate, showTitle, showDescription }) => {
|
||||
const { ref } = useInView({
|
||||
triggerOnce: false,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<FeedCard
|
||||
post={post}
|
||||
currentUserId={currentUser?.id}
|
||||
onNavigate={onNavigate}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileFeed;
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FeedCard } from './FeedCard';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useFeedData, FeedSortOption } from '@/hooks/useFeedData';
|
||||
import { useFeedCache } from '@/contexts/FeedCacheContext';
|
||||
import { useOrganization } from '@/contexts/OrganizationContext';
|
||||
import { FeedPost } from '@/modules/posts/client-posts';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
interface MobileFeedProps {
|
||||
source?: 'home' | 'collection' | 'tag' | 'user' | 'search';
|
||||
sourceId?: string;
|
||||
onNavigate?: (id: string) => void;
|
||||
sortBy?: FeedSortOption;
|
||||
categorySlugs?: string[];
|
||||
categoryIds?: string[];
|
||||
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
|
||||
visibilityFilter?: 'invisible' | 'private';
|
||||
center?: boolean;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showSocial?: boolean;
|
||||
}
|
||||
|
||||
export const MobileFeed: React.FC<MobileFeedProps> = ({
|
||||
source = 'home',
|
||||
sourceId,
|
||||
onNavigate,
|
||||
sortBy = 'latest',
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter,
|
||||
center,
|
||||
showTitle,
|
||||
showDescription,
|
||||
showAuthor = true,
|
||||
showSocial = true,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { getCache, saveCache } = useFeedCache();
|
||||
const { orgSlug, isOrgContext } = useOrganization();
|
||||
|
||||
// Use centralized feed hook
|
||||
const { posts, loading, error, hasMore } = useFeedData({
|
||||
source,
|
||||
sourceId,
|
||||
sortBy,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
contentType,
|
||||
visibilityFilter
|
||||
});
|
||||
|
||||
// Scroll Restoration Logic
|
||||
const cacheKey = `${source}-${sourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
|
||||
const hasRestoredScroll = useRef(false);
|
||||
const lastScrollY = useRef(window.scrollY);
|
||||
|
||||
// Track scroll position
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
lastScrollY.current = window.scrollY;
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Restore scroll when posts are populated
|
||||
React.useLayoutEffect(() => {
|
||||
if (posts.length > 0 && !hasRestoredScroll.current) {
|
||||
const cached = getCache(cacheKey);
|
||||
if (cached && cached.scrollY > 0) {
|
||||
window.scrollTo(0, cached.scrollY);
|
||||
}
|
||||
hasRestoredScroll.current = true;
|
||||
}
|
||||
}, [posts, cacheKey, getCache]);
|
||||
|
||||
// Save scroll position on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// We need to save the current state to cache
|
||||
// We can't easily get the *current* state of 'posts' inside this cleanup unless we use a ref or rely on the hook's cache
|
||||
// Actually, useFeedData already saves the data state. We just need to ensure scrollY is updated.
|
||||
// But useFeedData saves on render/update.
|
||||
// Let's simply update the existing cache entry with the new scrollY
|
||||
const currentCache = getCache(cacheKey);
|
||||
if (currentCache && lastScrollY.current > 0) {
|
||||
saveCache(cacheKey, { ...currentCache, scrollY: lastScrollY.current });
|
||||
}
|
||||
};
|
||||
}, [cacheKey, getCache, saveCache]);
|
||||
|
||||
|
||||
// Preloading Logic
|
||||
// We simply use <link rel="preload"> or simple JS Image object creation
|
||||
// But efficiently we want to do it based on scroll position.
|
||||
|
||||
// Simplest robust "Load 5 ahead" implementation:
|
||||
// We already have the URLs in `posts`.
|
||||
// We can observe the visible index and preload index + 1 to index + 5.
|
||||
|
||||
// Intersection Observer for Virtual Window / Preload trigger
|
||||
// Since we don't have a truly huge list (yet), we will render the list
|
||||
// but attach an observer to items to trigger preloading of subsequent items.
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-center text-red-500">
|
||||
Failed to load feed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>No posts yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSearchTabAllOrFiles = source === 'search' && (!contentType || contentType === 'files');
|
||||
|
||||
const renderItems = () => {
|
||||
if (!isSearchTabAllOrFiles) {
|
||||
return posts.map((post, index) => (
|
||||
<FeedItemWrapper
|
||||
key={post.id}
|
||||
post={post}
|
||||
index={index}
|
||||
posts={posts}
|
||||
currentUser={user}
|
||||
onNavigate={onNavigate}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showSocial={showSocial}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const getSearchGroup = (item: any): string => {
|
||||
if (item.type === 'page-vfs-folder') return 'Folders';
|
||||
if (item._searchSource === 'picture') return 'Pictures';
|
||||
if (item._searchSource === 'place') return 'Places';
|
||||
if (item._searchSource === 'file') {
|
||||
if (item.thumbnail_url || item.cover || (item.pictures && item.pictures.length > 0)) return 'Pictures';
|
||||
return 'Files';
|
||||
}
|
||||
if (item._searchSource === 'page') return 'Pages';
|
||||
if (item._searchSource === 'post') return 'Posts';
|
||||
return 'Posts';
|
||||
};
|
||||
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const post of posts) {
|
||||
const group = getSearchGroup(post);
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(post);
|
||||
}
|
||||
|
||||
const orderedGroups = ['Pages', 'Folders', 'Posts', 'Places', 'Pictures', 'Files'];
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (const group of orderedGroups) {
|
||||
if (groups.has(group)) {
|
||||
elements.push(
|
||||
<div key={`group-${group}`} className="px-3 py-1.5 bg-muted/40 border-y text-xs font-semibold uppercase tracking-wider text-muted-foreground sticky top-0 z-10 backdrop-blur-md">
|
||||
<T>{group}</T>
|
||||
</div>
|
||||
);
|
||||
elements.push(
|
||||
...groups.get(group)!.map((post: any, index: number) => (
|
||||
<FeedItemWrapper
|
||||
key={post.id}
|
||||
post={post}
|
||||
index={index}
|
||||
posts={posts}
|
||||
currentUser={user}
|
||||
onNavigate={onNavigate}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showSocial={showSocial}
|
||||
/>
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full ${center ? 'max-w-3xl mx-auto' : ''}`}>
|
||||
{renderItems()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper directly handles visibility logic to trigger preloads
|
||||
const FeedItemWrapper: React.FC<{
|
||||
post: FeedPost,
|
||||
index: number,
|
||||
posts: FeedPost[],
|
||||
currentUser: any,
|
||||
onNavigate?: (id: string) => void;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showSocial?: boolean;
|
||||
}> = ({ post, index, posts, currentUser, onNavigate, showTitle, showDescription, showAuthor, showSocial }) => {
|
||||
const { ref } = useInView({
|
||||
triggerOnce: false,
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<FeedCard
|
||||
post={post}
|
||||
currentUserId={currentUser?.id}
|
||||
onNavigate={onNavigate}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showSocial={showSocial}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileFeed;
|
||||
|
||||
@ -1,203 +1,218 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchCategories, type Category } from '@/modules/categories/client-categories';
|
||||
import { useAppConfig } from '@/hooks/useSystemInfo';
|
||||
import HomeWidget, { type HomeWidgetProps } from '@/components/widgets/HomeWidget';
|
||||
import { CategoryPickerField } from '@/components/widgets/CategoryPickerField';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface CategoryFeedWidgetProps extends HomeWidgetProps {
|
||||
/** ID of the selected category */
|
||||
categoryId?: string;
|
||||
/** Heading text displayed above the feed */
|
||||
heading?: string;
|
||||
/** Heading level: h1–h4 */
|
||||
headingLevel?: 'h1' | 'h2' | 'h3' | 'h4';
|
||||
/** Content type filter: posts, pages, or pictures */
|
||||
filterType?: 'posts' | 'pages' | 'pictures';
|
||||
/** Widget system props */
|
||||
isEditMode?: boolean;
|
||||
onPropsChange?: (props: Record<string, any>) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Flatten category tree to find a category by ID */
|
||||
const findCategoryById = (cats: Category[], id: string): Category | undefined => {
|
||||
for (const cat of cats) {
|
||||
if (cat.id === id) return cat;
|
||||
if (cat.children) {
|
||||
for (const rel of cat.children) {
|
||||
const found = findCategoryById([rel.child], id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const CategoryFeedWidget: React.FC<CategoryFeedWidgetProps> = ({
|
||||
categoryId,
|
||||
heading = '',
|
||||
headingLevel = 'h2',
|
||||
filterType,
|
||||
isEditMode = false,
|
||||
onPropsChange,
|
||||
// HomeWidget pass-through props
|
||||
sortBy,
|
||||
viewMode,
|
||||
showCategories,
|
||||
categorySlugs: propCategorySlugs,
|
||||
userId,
|
||||
showSortBar,
|
||||
showLayoutToggles,
|
||||
showFooter,
|
||||
center,
|
||||
columns,
|
||||
showTitle,
|
||||
showDescription,
|
||||
variables,
|
||||
searchQuery,
|
||||
...rest
|
||||
}) => {
|
||||
const appConfig = useAppConfig();
|
||||
const srcLang = appConfig?.i18n?.source_language;
|
||||
|
||||
// Fetch categories to resolve slug from ID
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang }),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
// Resolve slug from selected categoryId, fall back to inherited categorySlugs prop
|
||||
const selectedCategory = useMemo(
|
||||
() => categoryId ? findCategoryById(categories, categoryId) : undefined,
|
||||
[categories, categoryId]
|
||||
);
|
||||
|
||||
const categorySlugs = selectedCategory?.slug || propCategorySlugs || '';
|
||||
|
||||
// Build heading element
|
||||
const HeadingTag = headingLevel || 'h2';
|
||||
const headingClasses = cn(
|
||||
'font-bold text-foreground',
|
||||
HeadingTag === 'h1' && 'text-3xl lg:text-4xl',
|
||||
HeadingTag === 'h2' && 'text-2xl lg:text-3xl',
|
||||
HeadingTag === 'h3' && 'text-xl lg:text-2xl',
|
||||
HeadingTag === 'h4' && 'text-lg lg:text-xl',
|
||||
);
|
||||
|
||||
const renderHeading = () => {
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="px-4 pt-4 pb-2 flex flex-col gap-2">
|
||||
{/* Heading text input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Heading</T>
|
||||
</Label>
|
||||
<Input
|
||||
value={heading}
|
||||
onChange={(e) => onPropsChange?.({ heading: e.target.value })}
|
||||
placeholder={translate('Section heading...')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heading level selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Level</T>
|
||||
</Label>
|
||||
<Select
|
||||
value={headingLevel}
|
||||
onValueChange={(v) => onPropsChange?.({ headingLevel: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="h1">H1</SelectItem>
|
||||
<SelectItem value="h2">H2</SelectItem>
|
||||
<SelectItem value="h3">H3</SelectItem>
|
||||
<SelectItem value="h4">H4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Content type filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Filter</T>
|
||||
</Label>
|
||||
<Select
|
||||
value={filterType || 'all'}
|
||||
onValueChange={(v) => onPropsChange?.({ filterType: v === 'all' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all"><T>All</T></SelectItem>
|
||||
<SelectItem value="posts"><T>Posts</T></SelectItem>
|
||||
<SelectItem value="pages"><T>Pages</T></SelectItem>
|
||||
<SelectItem value="pictures"><T>Pictures</T></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Category</T>
|
||||
</Label>
|
||||
<CategoryPickerField
|
||||
value={categoryId || ''}
|
||||
onSelect={(id) => onPropsChange?.({ categoryId: id })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderHeading()}
|
||||
<HomeWidget
|
||||
sortBy={sortBy}
|
||||
viewMode={viewMode}
|
||||
showCategories={showCategories}
|
||||
categorySlugs={propCategorySlugs}
|
||||
categoryId={categoryId}
|
||||
userId={userId}
|
||||
showSortBar={showSortBar}
|
||||
showLayoutToggles={showLayoutToggles}
|
||||
showFooter={showFooter}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
center={center}
|
||||
columns={columns === 'auto' ? 'auto' : (Number(columns) || 4)}
|
||||
variables={variables}
|
||||
searchQuery={searchQuery}
|
||||
initialContentType={filterType}
|
||||
heading={heading}
|
||||
headingLevel={headingLevel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryFeedWidget;
|
||||
import React, { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchCategories, type Category } from '@/modules/categories/client-categories';
|
||||
import { useAppConfig } from '@/hooks/useSystemInfo';
|
||||
import HomeWidget, { type HomeWidgetProps } from '@/components/widgets/HomeWidget';
|
||||
import { CategoryPickerField } from '@/components/widgets/CategoryPickerField';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export interface CategoryFeedWidgetProps extends HomeWidgetProps {
|
||||
/** Content type filter (passed to HomeWidget as `initialContentType`) */
|
||||
filterType?: 'posts' | 'pages' | 'pictures';
|
||||
/** Show picture/post title on feed cards (HomeWidget; default true). */
|
||||
showTitle?: boolean;
|
||||
/** Show picture/post description on feed cards (HomeWidget; default false). */
|
||||
showDescription?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Flatten category tree to find a category by ID */
|
||||
const findCategoryById = (cats: Category[], id: string): Category | undefined => {
|
||||
for (const cat of cats) {
|
||||
if (cat.id === id) return cat;
|
||||
if (cat.children) {
|
||||
for (const rel of cat.children) {
|
||||
const found = findCategoryById([rel.child], id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const CategoryFeedWidget: React.FC<CategoryFeedWidgetProps> = ({
|
||||
categoryId,
|
||||
heading = '',
|
||||
headingLevel = 'h2',
|
||||
filterType,
|
||||
isEditMode = false,
|
||||
onPropsChange,
|
||||
// HomeWidget pass-through props
|
||||
sortBy,
|
||||
viewMode,
|
||||
showCategories,
|
||||
categorySlugs: propCategorySlugs,
|
||||
userId,
|
||||
showSortBar,
|
||||
showLayoutToggles,
|
||||
showFooter,
|
||||
center,
|
||||
columns,
|
||||
showTitle,
|
||||
showDescription,
|
||||
showAuthor,
|
||||
showSocial,
|
||||
variables,
|
||||
searchQuery,
|
||||
...rest
|
||||
}) => {
|
||||
const appConfig = useAppConfig();
|
||||
const srcLang = appConfig?.i18n?.source_language;
|
||||
|
||||
// Fetch categories to resolve slug from ID
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang }),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
// Resolve slug from selected categoryId, fall back to inherited categorySlugs prop
|
||||
const selectedCategory = useMemo(
|
||||
() => categoryId ? findCategoryById(categories, categoryId) : undefined,
|
||||
[categories, categoryId]
|
||||
);
|
||||
|
||||
const categorySlugs = selectedCategory?.slug || propCategorySlugs || '';
|
||||
|
||||
// Build heading element
|
||||
const HeadingTag = headingLevel || 'h2';
|
||||
const headingClasses = cn(
|
||||
'font-bold text-foreground',
|
||||
HeadingTag === 'h1' && 'text-3xl lg:text-4xl',
|
||||
HeadingTag === 'h2' && 'text-2xl lg:text-3xl',
|
||||
HeadingTag === 'h3' && 'text-xl lg:text-2xl',
|
||||
HeadingTag === 'h4' && 'text-lg lg:text-xl',
|
||||
);
|
||||
|
||||
const renderHeading = () => {
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="px-4 pt-4 pb-2 flex flex-col gap-2">
|
||||
{/* Heading text input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Heading</T>
|
||||
</Label>
|
||||
<Input
|
||||
value={heading}
|
||||
onChange={(e) => onPropsChange?.({ heading: e.target.value })}
|
||||
placeholder={translate('Section heading...')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heading level selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Level</T>
|
||||
</Label>
|
||||
<Select
|
||||
value={headingLevel}
|
||||
onValueChange={(v) => onPropsChange?.({ headingLevel: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="h1">H1</SelectItem>
|
||||
<SelectItem value="h2">H2</SelectItem>
|
||||
<SelectItem value="h3">H3</SelectItem>
|
||||
<SelectItem value="h4">H4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Content type filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Filter</T>
|
||||
</Label>
|
||||
<Select
|
||||
value={filterType || 'all'}
|
||||
onValueChange={(v) => onPropsChange?.({ filterType: v === 'all' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all"><T>All</T></SelectItem>
|
||||
<SelectItem value="posts"><T>Posts</T></SelectItem>
|
||||
<SelectItem value="pages"><T>Pages</T></SelectItem>
|
||||
<SelectItem value="pictures"><T>Pictures</T></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Category picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap w-16">
|
||||
<T>Category</T>
|
||||
</Label>
|
||||
<CategoryPickerField
|
||||
value={categoryId || ''}
|
||||
onSelect={(id) => onPropsChange?.({ categoryId: id })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1 border-t border-border/60">
|
||||
<Label className="text-xs text-muted-foreground"><T>Show Title</T></Label>
|
||||
<Switch
|
||||
checked={showTitle !== false}
|
||||
onCheckedChange={(v) => onPropsChange?.({ showTitle: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground"><T>Show Description</T></Label>
|
||||
<Switch
|
||||
checked={!!showDescription}
|
||||
onCheckedChange={(v) => onPropsChange?.({ showDescription: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderHeading()}
|
||||
<HomeWidget
|
||||
sortBy={sortBy}
|
||||
viewMode={viewMode}
|
||||
showCategories={showCategories}
|
||||
categorySlugs={categorySlugs}
|
||||
categoryId={categoryId}
|
||||
userId={userId}
|
||||
showSortBar={showSortBar}
|
||||
showLayoutToggles={showLayoutToggles}
|
||||
showFooter={showFooter}
|
||||
showTitle={showTitle}
|
||||
showDescription={showDescription}
|
||||
showAuthor={showAuthor}
|
||||
showSocial={showSocial}
|
||||
center={center}
|
||||
columns={columns === 'auto' ? 'auto' : (Number(columns) || 4)}
|
||||
variables={variables}
|
||||
searchQuery={searchQuery}
|
||||
initialContentType={filterType}
|
||||
heading={heading}
|
||||
headingLevel={headingLevel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryFeedWidget;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,212 +1,217 @@
|
||||
import React from 'react';
|
||||
import { Heart, MessageCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ResponsiveImage from "@/components/ResponsiveImage";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||
import type { MediaRendererProps } from "@/lib/mediaRegistry";
|
||||
|
||||
/** Extensible preset configuration for card display */
|
||||
export interface CardPreset {
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
|
||||
variant?: 'grid' | 'feed';
|
||||
responsive?: any;
|
||||
showContent?: boolean;
|
||||
showHeader?: boolean;
|
||||
overlayMode?: 'hover' | 'always';
|
||||
authorAvatarUrl?: string | null;
|
||||
created_at?: string;
|
||||
apiUrl?: string;
|
||||
versionCount?: number;
|
||||
preset?: CardPreset;
|
||||
className?: string;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const PageCard: React.FC<PageCardProps> = ({
|
||||
id,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
title,
|
||||
author,
|
||||
authorId,
|
||||
authorAvatarUrl,
|
||||
likes,
|
||||
comments,
|
||||
isLiked,
|
||||
description,
|
||||
onClick,
|
||||
onLike,
|
||||
created_at,
|
||||
variant = 'grid',
|
||||
responsive,
|
||||
showContent = true,
|
||||
showHeader = true,
|
||||
overlayMode = 'hover',
|
||||
apiUrl,
|
||||
versionCount,
|
||||
preset,
|
||||
type,
|
||||
className,
|
||||
showTitle,
|
||||
showDescription,
|
||||
showAuthor,
|
||||
showActions
|
||||
}) => {
|
||||
// Determine image source
|
||||
// If url is missing or empty, fallback to picsum
|
||||
// For PAGE_EXTERNAL, currently 'url' is the link and 'thumbnailUrl' is the image.
|
||||
|
||||
const displayImage = thumbnailUrl || url || "https://picsum.photos/640";
|
||||
|
||||
const handleLike = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onLike?.();
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(id);
|
||||
};
|
||||
|
||||
if (variant === 'feed') {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-card"
|
||||
className="group relative overflow-hidden transition-all duration-300 cursor-pointer w-full mb-4 bg-card"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
|
||||
<div className={`relative w-full aspect-[16/9] overflow-hidden bg-muted`}>
|
||||
<ResponsiveImage
|
||||
src={displayImage}
|
||||
alt={title}
|
||||
className="w-full h-full"
|
||||
imgClassName="w-full h-full object-contain transition-transform duration-300"
|
||||
sizes="100vw"
|
||||
data={responsive}
|
||||
apiUrl={apiUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showContent && (
|
||||
<div className="pb-2 space-y-2">
|
||||
{/* Author + Actions row */}
|
||||
<div className="flex items-center justify-between px-2 pt-2">
|
||||
{(showAuthor ?? preset?.showAuthor) !== false && (
|
||||
<UserAvatarBlock
|
||||
userId={authorId}
|
||||
avatarUrl={authorAvatarUrl}
|
||||
displayName={author}
|
||||
className="w-8 h-8"
|
||||
showDate={true}
|
||||
createdAt={created_at}
|
||||
/>
|
||||
)}
|
||||
{(showActions ?? preset?.showActions) !== false && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
|
||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-red-500 text-red-500" : ""}`} />
|
||||
<span>{likes}</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="px-0 gap-1">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
<span>{comments}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<div className="px-4 space-y-1">
|
||||
{(showTitle ?? preset?.showTitle) !== false && (
|
||||
<div className="font-semibold text-sm">{title}</div>
|
||||
)}
|
||||
{(showDescription ?? preset?.showDescription) !== false && description && (
|
||||
<div className="text-sm text-foreground/90 line-clamp-3">
|
||||
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid Variant
|
||||
return (
|
||||
<div
|
||||
data-testid="page-card"
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full flex flex-col ${preset?.showTitle ? '' : 'md:aspect-square'} ${className || ''}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className={`relative w-full overflow-hidden ${className?.includes('h-full') ? 'flex-1 min-h-0' : 'aspect-square'}`}>
|
||||
<ResponsiveImage
|
||||
src={displayImage}
|
||||
alt={title}
|
||||
className="w-full h-full"
|
||||
imgClassName="w-full h-full object-contain transition-transform duration-300"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 50vw"
|
||||
responsiveSizes={[640, 1024, 1440]}
|
||||
data={responsive}
|
||||
apiUrl={apiUrl}
|
||||
/>
|
||||
|
||||
{/* TESTING: Entire hover overlay disabled */}
|
||||
{false && showContent && (
|
||||
<div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{preset?.showAuthor !== false && (
|
||||
<UserAvatarBlock
|
||||
userId={authorId}
|
||||
avatarUrl={authorAvatarUrl}
|
||||
displayName={author}
|
||||
hoverStyle={true}
|
||||
showDate={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{preset?.showActions !== false && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleLike}
|
||||
className={`h-8 w-8 p-0 ${isLiked ? "text-red-500" : "text-white hover:text-red-500"}`}
|
||||
>
|
||||
<Heart className="h-4 w-4" fill={isLiked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info bar below image (preset-driven) */}
|
||||
{(preset?.showTitle || preset?.showDescription) && (title || description) && (
|
||||
<div className="px-2.5 py-2 border-t bg-muted/40">
|
||||
{preset?.showTitle && title && (
|
||||
<h3 className="text-sm font-medium truncate">{title}</h3>
|
||||
)}
|
||||
{preset?.showDescription && description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageCard;
|
||||
import React from 'react';
|
||||
import { Heart, MessageCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ResponsiveImage from "@/components/ResponsiveImage";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||
import type { MediaRendererProps } from "@/lib/mediaRegistry";
|
||||
|
||||
/** Extensible preset configuration for card display */
|
||||
export interface CardPreset {
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
|
||||
variant?: 'grid' | 'feed';
|
||||
responsive?: any;
|
||||
showContent?: boolean;
|
||||
showHeader?: boolean;
|
||||
overlayMode?: 'hover' | 'always';
|
||||
authorAvatarUrl?: string | null;
|
||||
created_at?: string;
|
||||
apiUrl?: string;
|
||||
versionCount?: number;
|
||||
preset?: CardPreset;
|
||||
className?: string;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showAuthor?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const PageCard: React.FC<PageCardProps> = ({
|
||||
id,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
title,
|
||||
author,
|
||||
authorId,
|
||||
authorAvatarUrl,
|
||||
likes,
|
||||
comments,
|
||||
isLiked,
|
||||
description,
|
||||
onClick,
|
||||
onLike,
|
||||
created_at,
|
||||
variant = 'grid',
|
||||
responsive,
|
||||
showContent = true,
|
||||
showHeader = true,
|
||||
overlayMode = 'hover',
|
||||
apiUrl,
|
||||
versionCount,
|
||||
preset,
|
||||
type,
|
||||
className,
|
||||
showTitle,
|
||||
showDescription,
|
||||
showAuthor,
|
||||
showActions
|
||||
}) => {
|
||||
// Determine image source
|
||||
// If url is missing or empty, fallback to picsum
|
||||
// For PAGE_EXTERNAL, currently 'url' is the link and 'thumbnailUrl' is the image.
|
||||
|
||||
const displayImage = thumbnailUrl || url || "https://picsum.photos/640";
|
||||
|
||||
/** When false/undefined, grid uses square crop; when true, allow flexible height below image. */
|
||||
const gridTitleSlot = !!(showTitle ?? preset?.showTitle);
|
||||
const gridTitleOn = (showTitle ?? preset?.showTitle) !== false;
|
||||
const gridDescriptionOn = (showDescription ?? preset?.showDescription) !== false;
|
||||
|
||||
const handleLike = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onLike?.();
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(id);
|
||||
};
|
||||
|
||||
if (variant === 'feed') {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-card"
|
||||
className="group relative overflow-hidden transition-all duration-300 cursor-pointer w-full mb-4 bg-card"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
|
||||
<div className={`relative w-full aspect-[16/9] overflow-hidden bg-muted`}>
|
||||
<ResponsiveImage
|
||||
src={displayImage}
|
||||
alt={title}
|
||||
className="w-full h-full"
|
||||
imgClassName="w-full h-full object-contain transition-transform duration-300"
|
||||
sizes="100vw"
|
||||
data={responsive}
|
||||
apiUrl={apiUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showContent && (
|
||||
<div className="pb-2 space-y-2">
|
||||
{/* Author + Actions row */}
|
||||
<div className="flex items-center justify-between px-2 pt-2">
|
||||
{(showAuthor ?? preset?.showAuthor) !== false && (
|
||||
<UserAvatarBlock
|
||||
userId={authorId}
|
||||
avatarUrl={authorAvatarUrl}
|
||||
displayName={author}
|
||||
className="w-8 h-8"
|
||||
showDate={true}
|
||||
createdAt={created_at}
|
||||
/>
|
||||
)}
|
||||
{(showActions ?? preset?.showActions) !== false && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
|
||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-red-500 text-red-500" : ""}`} />
|
||||
<span>{likes}</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="px-0 gap-1">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
<span>{comments}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<div className="px-4 space-y-1">
|
||||
{(showTitle ?? preset?.showTitle) !== false && (
|
||||
<div className="font-semibold text-sm">{title}</div>
|
||||
)}
|
||||
{(showDescription ?? preset?.showDescription) !== false && description && (
|
||||
<div className="text-sm text-foreground/90 line-clamp-3">
|
||||
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid Variant
|
||||
return (
|
||||
<div
|
||||
data-testid="page-card"
|
||||
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full flex flex-col ${gridTitleSlot ? '' : 'md:aspect-square'} ${className || ''}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className={`relative w-full overflow-hidden ${className?.includes('h-full') ? 'flex-1 min-h-0' : 'aspect-square'}`}>
|
||||
<ResponsiveImage
|
||||
src={displayImage}
|
||||
alt={title}
|
||||
className="w-full h-full"
|
||||
imgClassName="w-full h-full object-contain transition-transform duration-300"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 50vw"
|
||||
responsiveSizes={[640, 1024, 1440]}
|
||||
data={responsive}
|
||||
apiUrl={apiUrl}
|
||||
/>
|
||||
|
||||
{/* TESTING: Entire hover overlay disabled */}
|
||||
{false && showContent && (
|
||||
<div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{preset?.showAuthor !== false && (
|
||||
<UserAvatarBlock
|
||||
userId={authorId}
|
||||
avatarUrl={authorAvatarUrl}
|
||||
displayName={author}
|
||||
hoverStyle={true}
|
||||
showDate={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{preset?.showActions !== false && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleLike}
|
||||
className={`h-8 w-8 p-0 ${isLiked ? "text-red-500" : "text-white hover:text-red-500"}`}
|
||||
>
|
||||
<Heart className="h-4 w-4" fill={isLiked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info bar below image (preset or explicit props) */}
|
||||
{(gridTitleOn || gridDescriptionOn) && (title || description) && (
|
||||
<div className="px-2.5 py-2 border-t bg-muted/40">
|
||||
{gridTitleOn && title && (
|
||||
<h3 className="text-sm font-medium truncate">{title}</h3>
|
||||
)}
|
||||
{gridDescriptionOn && description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageCard;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,235 +1,236 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LayoutGrid, Edit3, MoreVertical, Trash2, Save, X, Grid, FolderTree, ExternalLink, Eye, EyeOff, Lock } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { T } from '@/i18n';
|
||||
|
||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import { ExportDropdown } from "../../components/ExportDropdown";
|
||||
|
||||
import { PostMediaItem, UserProfile } from "../../types";
|
||||
|
||||
interface CompactPostHeaderProps {
|
||||
isEditMode: boolean;
|
||||
post: any;
|
||||
localPost: any;
|
||||
setLocalPost: (post: any) => void;
|
||||
mediaItem: PostMediaItem;
|
||||
authorProfile: UserProfile;
|
||||
isOwner: boolean;
|
||||
embedded?: boolean;
|
||||
onViewModeChange: (mode: 'thumbs' | 'compact') => void;
|
||||
onExportMarkdown: (type: 'hugo' | 'obsidian' | 'raw') => void;
|
||||
onSaveChanges: () => void;
|
||||
onEditModeToggle: () => void;
|
||||
onEditPost: () => void;
|
||||
onDeletePicture: () => void;
|
||||
onDeletePost: () => void;
|
||||
onCategoryManagerOpen?: () => void;
|
||||
mediaItems: PostMediaItem[];
|
||||
localMediaItems?: PostMediaItem[];
|
||||
}
|
||||
|
||||
export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
|
||||
isEditMode,
|
||||
post,
|
||||
localPost,
|
||||
setLocalPost,
|
||||
mediaItem,
|
||||
authorProfile,
|
||||
isOwner,
|
||||
embedded = false,
|
||||
onViewModeChange,
|
||||
onExportMarkdown,
|
||||
onSaveChanges,
|
||||
onEditModeToggle,
|
||||
onEditPost,
|
||||
onDeletePicture,
|
||||
onDeletePost,
|
||||
onCategoryManagerOpen,
|
||||
mediaItems,
|
||||
localMediaItems
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Post Title/Description + Actions — same row */}
|
||||
{isEditMode && !post?.isPseudo ? (
|
||||
<div className="p-3 border-b space-y-2">
|
||||
<Input
|
||||
value={localPost?.title || ''}
|
||||
onChange={(e) => setLocalPost && setLocalPost({ ...localPost!, title: e.target.value })}
|
||||
className="font-bold text-lg"
|
||||
placeholder="Post Title"
|
||||
/>
|
||||
<Textarea
|
||||
value={localPost?.description || ''}
|
||||
onChange={(e) => setLocalPost && setLocalPost({ ...localPost!, description: e.target.value })}
|
||||
className="text-sm"
|
||||
placeholder="Post Description"
|
||||
/>
|
||||
{/* Visibility */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={localPost?.settings?.visibility || 'public'}
|
||||
onValueChange={(value) => setLocalPost && setLocalPost({
|
||||
...localPost!,
|
||||
settings: { ...(localPost?.settings || {}), visibility: value }
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue placeholder="Visibility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public"><div className="flex items-center gap-2"><Eye className="h-3 w-3" /><T>Public</T></div></SelectItem>
|
||||
<SelectItem value="listed"><div className="flex items-center gap-2"><EyeOff className="h-3 w-3" /><T>Unlisted</T></div></SelectItem>
|
||||
<SelectItem value="private"><div className="flex items-center gap-2"><Lock className="h-3 w-3" /><T>Private</T></div></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{localPost?.settings?.visibility === 'listed' && <T>Accessible only via direct link.</T>}
|
||||
{localPost?.settings?.visibility === 'private' && <T>Only you can see this post.</T>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Edit-mode actions */}
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onSaveChanges} className="h-8 w-8 p-0 text-green-600 hover:text-green-700" title="Save changes">
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive" title="Cancel edit">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 border-b">
|
||||
<div className={`flex gap-3 ${embedded ? 'flex-col-reverse' : 'items-start'}`}>
|
||||
{/* Left column: title, description, categories */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{post && (post.description || (post.title && post.title !== mediaItem?.title)) && (
|
||||
<>
|
||||
{post.title && post.title !== mediaItem?.title && (<h1 className="text-lg font-bold mb-1">{post.title}</h1>)}
|
||||
{post.description && <MarkdownRenderer content={post.description} className="prose-sm text-sm text-foreground/90 mb-2" />}
|
||||
|
||||
{/* Category Breadcrumbs */}
|
||||
{(() => {
|
||||
const displayPaths = (post as any).category_paths || [];
|
||||
if (displayPaths.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
{displayPaths.map((path: any[], pathIdx: number) => (
|
||||
<div key={pathIdx} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FolderTree className="h-4 w-4 shrink-0" />
|
||||
{path.map((cat: any, idx: number) => (
|
||||
<span key={cat.id} className="flex items-center">
|
||||
{idx > 0 && <span className="mx-1 text-muted-foreground/50">/</span>}
|
||||
<a href={`/categories/${cat.slug}`} className="hover:text-primary transition-colors hover:underline">
|
||||
{cat.name}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center bg-muted/50 rounded-lg p-1 mr-1 border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground"
|
||||
onClick={() => onViewModeChange('thumbs')}
|
||||
title="Thumbs View"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact View"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Open Standalone - break out of embedded view */}
|
||||
{embedded && post?.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-primary"
|
||||
onClick={() => window.open(`/post/${post.id}`, '_blank')}
|
||||
title="Open in full page"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ExportDropdown
|
||||
post={isEditMode ? localPost || null : post || null}
|
||||
mediaItems={isEditMode ? (localMediaItems as any) || [] : mediaItems}
|
||||
authorProfile={authorProfile}
|
||||
onExportMarkdown={() => onExportMarkdown('raw')}
|
||||
/>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-7 w-7 p-0 text-muted-foreground hover:text-primary" title="Inline Edit">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEditPost}><Edit3 className="mr-2 h-4 w-4" /><span>Edit Post Wizard</span></DropdownMenuItem>
|
||||
{onCategoryManagerOpen && <DropdownMenuItem onClick={onCategoryManagerOpen}><FolderTree className="mr-2 h-4 w-4" /><span>Manage Categories</span></DropdownMenuItem>}
|
||||
<DropdownMenuItem onClick={onDeletePicture} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete this picture</span></DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDeletePost} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete whole post</span></DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author / Date Row */}
|
||||
<div className="px-3 py-2 border-b">
|
||||
<UserAvatarBlock
|
||||
userId={mediaItem.user_id}
|
||||
avatarUrl={authorProfile?.avatar_url}
|
||||
displayName={authorProfile?.display_name}
|
||||
createdAt={mediaItem.created_at}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LayoutGrid, Edit3, MoreVertical, Trash2, Save, X, Grid, FolderTree, ExternalLink, Eye, EyeOff, Lock } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { T } from '@/i18n';
|
||||
|
||||
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
||||
import UserAvatarBlock from "@/components/UserAvatarBlock";
|
||||
import { ExportDropdown } from "../../components/ExportDropdown";
|
||||
|
||||
import { PostMediaItem, UserProfile } from "../../types";
|
||||
|
||||
interface CompactPostHeaderProps {
|
||||
isEditMode: boolean;
|
||||
post: any;
|
||||
localPost: any;
|
||||
setLocalPost: (post: any) => void;
|
||||
mediaItem: PostMediaItem;
|
||||
authorProfile: UserProfile;
|
||||
isOwner: boolean;
|
||||
embedded?: boolean;
|
||||
onViewModeChange: (mode: 'thumbs' | 'compact') => void;
|
||||
onExportMarkdown: (type: 'hugo' | 'obsidian' | 'raw') => void;
|
||||
onSaveChanges: () => void;
|
||||
onEditModeToggle: () => void;
|
||||
onEditPost: () => void;
|
||||
onDeletePicture: () => void;
|
||||
onDeletePost: () => void;
|
||||
onCategoryManagerOpen?: () => void;
|
||||
mediaItems: PostMediaItem[];
|
||||
localMediaItems?: PostMediaItem[];
|
||||
}
|
||||
|
||||
export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
|
||||
isEditMode,
|
||||
post,
|
||||
localPost,
|
||||
setLocalPost,
|
||||
mediaItem,
|
||||
authorProfile,
|
||||
isOwner,
|
||||
embedded = false,
|
||||
onViewModeChange,
|
||||
onExportMarkdown,
|
||||
onSaveChanges,
|
||||
onEditModeToggle,
|
||||
onEditPost,
|
||||
onDeletePicture,
|
||||
onDeletePost,
|
||||
onCategoryManagerOpen,
|
||||
mediaItems,
|
||||
localMediaItems
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Post Title/Description + Actions — same row */}
|
||||
{isEditMode && !post?.isPseudo ? (
|
||||
<div className="p-3 border-b space-y-2">
|
||||
<Input
|
||||
value={localPost?.title || ''}
|
||||
onChange={(e) => setLocalPost && setLocalPost({ ...localPost!, title: e.target.value })}
|
||||
className="font-bold text-lg"
|
||||
placeholder="Post Title"
|
||||
/>
|
||||
<Textarea
|
||||
value={localPost?.description || ''}
|
||||
onChange={(e) => setLocalPost && setLocalPost({ ...localPost!, description: e.target.value })}
|
||||
className="text-sm"
|
||||
placeholder="Post Description"
|
||||
/>
|
||||
{/* Visibility */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={localPost?.settings?.visibility || 'public'}
|
||||
onValueChange={(value) => setLocalPost && setLocalPost({
|
||||
...localPost!,
|
||||
settings: { ...(localPost?.settings || {}), visibility: value }
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue placeholder="Visibility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public"><div className="flex items-center gap-2"><Eye className="h-3 w-3" /><T>Public</T></div></SelectItem>
|
||||
<SelectItem value="listed"><div className="flex items-center gap-2"><EyeOff className="h-3 w-3" /><T>Unlisted</T></div></SelectItem>
|
||||
<SelectItem value="private"><div className="flex items-center gap-2"><Lock className="h-3 w-3" /><T>Private</T></div></SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{localPost?.settings?.visibility === 'listed' && <T>Accessible only via direct link.</T>}
|
||||
{localPost?.settings?.visibility === 'private' && <T>Only you can see this post.</T>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Edit-mode actions */}
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onSaveChanges} className="h-8 w-8 p-0 text-green-600 hover:text-green-700" title="Save changes">
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive" title="Cancel edit">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 border-b">
|
||||
{/* Full-width text, then toolbar on its own row — avoids squeezing description next to Export / wide controls */}
|
||||
<div className={`flex flex-col gap-3 ${embedded ? 'flex-col-reverse' : ''}`}>
|
||||
{/* Title, description, categories — always full width of the column */}
|
||||
<div className="min-w-0 w-full">
|
||||
{post && (post.description || (post.title && post.title !== mediaItem?.title)) && (
|
||||
<>
|
||||
{post.title && post.title !== mediaItem?.title && (<h1 className="text-lg font-bold mb-1">{post.title}</h1>)}
|
||||
{post.description && <MarkdownRenderer content={post.description} className="prose-sm text-sm text-foreground/90 mb-2" />}
|
||||
|
||||
{/* Category Breadcrumbs */}
|
||||
{(() => {
|
||||
const displayPaths = (post as any).category_paths || [];
|
||||
if (displayPaths.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
{displayPaths.map((path: any[], pathIdx: number) => (
|
||||
<div key={pathIdx} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FolderTree className="h-4 w-4 shrink-0" />
|
||||
{path.map((cat: any, idx: number) => (
|
||||
<span key={cat.id} className="flex items-center">
|
||||
{idx > 0 && <span className="mx-1 text-muted-foreground/50">/</span>}
|
||||
<a href={`/categories/${cat.slug}`} className="hover:text-primary transition-colors hover:underline">
|
||||
{cat.name}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions row — does not share a horizontal flex row with markdown */}
|
||||
<div className="flex items-center justify-end gap-1 flex-wrap shrink-0">
|
||||
<div className="flex items-center bg-muted/50 rounded-lg p-1 mr-1 border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground"
|
||||
onClick={() => onViewModeChange('thumbs')}
|
||||
title="Thumbs View"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact View"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Open Standalone - break out of embedded view */}
|
||||
{embedded && post?.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-primary"
|
||||
onClick={() => window.open(`/post/${post.id}`, '_blank')}
|
||||
title="Open in full page"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ExportDropdown
|
||||
post={isEditMode ? localPost || null : post || null}
|
||||
mediaItems={isEditMode ? (localMediaItems as any) || [] : mediaItems}
|
||||
authorProfile={authorProfile}
|
||||
onExportMarkdown={() => onExportMarkdown('raw')}
|
||||
/>
|
||||
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onEditModeToggle} className="h-7 w-7 p-0 text-muted-foreground hover:text-primary" title="Inline Edit">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEditPost}><Edit3 className="mr-2 h-4 w-4" /><span>Edit Post Wizard</span></DropdownMenuItem>
|
||||
{onCategoryManagerOpen && <DropdownMenuItem onClick={onCategoryManagerOpen}><FolderTree className="mr-2 h-4 w-4" /><span>Manage Categories</span></DropdownMenuItem>}
|
||||
<DropdownMenuItem onClick={onDeletePicture} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete this picture</span></DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDeletePost} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete whole post</span></DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author / Date Row */}
|
||||
<div className="px-3 py-2 border-b">
|
||||
<UserAvatarBlock
|
||||
userId={mediaItem.user_id}
|
||||
avatarUrl={authorProfile?.avatar_url}
|
||||
displayName={authorProfile?.display_name}
|
||||
createdAt={mediaItem.created_at}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user