cat / home - widget props

This commit is contained in:
lovebird 2026-04-14 14:45:46 +02:00
parent 9f5f7bf60d
commit 38d718b08c
13 changed files with 6257 additions and 6139 deletions

View File

@ -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

View File

@ -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>
);
};

View File

@ -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

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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: h1h4 */
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

View File

@ -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

View File

@ -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>
</>
);
};