From 38d718b08c9bfc1f1f710c0aa94f1b00264aad98 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 14 Apr 2026 14:45:46 +0200 Subject: [PATCH] cat / home - widget props --- packages/ui/docs/product-pacbot.md | 9 +- packages/ui/src/components/ListLayout.tsx | 736 ++--- packages/ui/src/components/MediaCard.tsx | 559 ++-- packages/ui/src/components/PhotoCard.tsx | 1260 ++++---- packages/ui/src/components/VideoCard.tsx | 1389 ++++---- packages/ui/src/components/feed/FeedCard.tsx | 379 +-- .../ui/src/components/feed/MobileFeed.tsx | 486 +-- .../components/widgets/CategoryFeedWidget.tsx | 421 +-- .../ui/src/components/widgets/HomeWidget.tsx | 1201 +++---- packages/ui/src/lib/registerWidgets.ts | 2848 +++++++++-------- packages/ui/src/modules/pages/PageCard.tsx | 429 +-- packages/ui/src/modules/posts/PostPage.tsx | 2208 ++++++------- .../components/CompactPostHeader.tsx | 471 +-- 13 files changed, 6257 insertions(+), 6139 deletions(-) diff --git a/packages/ui/docs/product-pacbot.md b/packages/ui/docs/product-pacbot.md index 8374388e..8a7dc067 100644 --- a/packages/ui/docs/product-pacbot.md +++ b/packages/ui/docs/product-pacbot.md @@ -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 diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index 2063cb0f..ee0e9b9f 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -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 ( -
-
-
-

- {item.title || "Untitled"} -

- {domain && ( - - - {domain} - - )} -
- - {preset?.showDescription !== false && item.description && ( -
- {item.description} -
- )} - -
-
{ e.stopPropagation(); }}> - -
- {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} - -
- {(item.likes_count || 0) >= 10 && ( - - - {item.likes_count} - - )} - {((item.comments && item.comments[0]?.count) || 0) >= 10 && ( - - - {(item.comments && item.comments[0]?.count) || 0} - - )} -
-
-
- - {/* Thumbnail for quick context */} - {/* Thumbnail for quick context */} -
- {item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url ? ( - - ) : ( -
-
-
- )} -
-
- ); -}; - -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(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
Loading...
; - } - - if (feedPosts.length === 0) { - return
No posts found.
; - } - - const renderItems = (isMobileView: boolean) => { - const shouldGroup = navigationSource === 'search' && (!contentType || contentType === 'files'); - - if (!shouldGroup) { - return feedPosts.map((post: any) => ( -
- handleItemClick(post)} - preset={preset} - /> -
- )); - } - - const groups = new Map(); - 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( -
- {group} -
- ); - elements.push( - ...groups.get(group)!.map((post: any) => ( -
- handleItemClick(post)} - preset={preset} - /> -
- )) - ); - } - } - - return elements; - }; - - if (!isMobile) { - // Desktop Split Layout - return ( -
- {/* Left: List */} -
-
- {renderItems(false)} - {hasMore && ( -
- -
- )} -
-
- - {/* Right: Detail */} -
- {selectedId ? ( - (() => { - const selectedPost = feedPosts.find((p: any) => p.id === selectedId); - if (!selectedPost) { - return ( -
- Select an item to view details -
- ); - } - 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 ( - Loading...
}> - - - ); - } - - if (postAny?.type === 'place-search' && postAny.meta?.url) { - return ( -
-

{postAny.title}

- {postAny.description && ( -

{postAny.description}

- )} - - Open place details - -
- ); - } - - return ( - Loading...
}> - - - ); - })() - ) : ( -
- Select an item to view details -
- )} -
- - ); - } - - // Mobile Layout - return ( -
- {renderItems(true)} - {hasMore && ( -
- -
- )} -
- ); -}; + +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 ( +
+
+
+

+ {item.title || "Untitled"} +

+ {domain && ( + + + {domain} + + )} +
+ + {preset?.showDescription !== false && item.description && ( +
+ {item.description} +
+ )} + + {(preset?.showAuthor !== false || preset?.showActions !== false) && ( +
+ {preset?.showAuthor !== false && ( + <> +
{ e.stopPropagation(); }}> + +
+ {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })} + + )} + + {preset?.showActions !== false && ( +
+ {(item.likes_count || 0) >= 10 && ( + + + {item.likes_count} + + )} + {((item.comments && item.comments[0]?.count) || 0) >= 10 && ( + + + {(item.comments && item.comments[0]?.count) || 0} + + )} +
+ )} +
+ )} +
+ + {/* Thumbnail for quick context */} + {/* Thumbnail for quick context */} +
+ {item.thumbnail_url || item.cover?.image_url || item.pictures?.[0]?.image_url ? ( + + ) : ( +
+
+
+ )} +
+
+ ); +}; + +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(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
Loading...
; + } + + if (feedPosts.length === 0) { + return
No posts found.
; + } + + const renderItems = (isMobileView: boolean) => { + const shouldGroup = navigationSource === 'search' && (!contentType || contentType === 'files'); + + if (!shouldGroup) { + return feedPosts.map((post: any) => ( +
+ handleItemClick(post)} + preset={preset} + /> +
+ )); + } + + const groups = new Map(); + 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( +
+ {group} +
+ ); + elements.push( + ...groups.get(group)!.map((post: any) => ( +
+ handleItemClick(post)} + preset={preset} + /> +
+ )) + ); + } + } + + return elements; + }; + + if (!isMobile) { + // Desktop Split Layout + return ( +
+ {/* Left: List */} +
+
+ {renderItems(false)} + {hasMore && ( +
+ +
+ )} +
+
+ + {/* Right: Detail */} +
+ {selectedId ? ( + (() => { + const selectedPost = feedPosts.find((p: any) => p.id === selectedId); + if (!selectedPost) { + return ( +
+ Select an item to view details +
+ ); + } + 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 ( + Loading...
}> + + + ); + } + + if (postAny?.type === 'place-search' && postAny.meta?.url) { + return ( +
+

{postAny.title}

+ {postAny.description && ( +

{postAny.description}

+ )} + + Open place details + +
+ ); + } + + return ( + Loading...
}> + + + ); + })() + ) : ( +
+ Select an item to view details +
+ )} +
+ + ); + } + + // Mobile Layout + return ( +
+ {renderItems(true)} + {hasMore && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/MediaCard.tsx b/packages/ui/src/components/MediaCard.tsx index 94845ee7..1968054d 100644 --- a/packages/ui/src/components/MediaCard.tsx +++ b/packages/ui/src/components/MediaCard.tsx @@ -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 = ({ - 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 ( -
- -
- ); - } - - if (normalizedType === MEDIA_TYPES.VIDEO_INTERN || normalizedType === MEDIA_TYPES.VIDEO_EXTERN) { - return ( - 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 ( -
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" - > -
- - {title} -
- {showContent && ( -
-
- - {title} -
- {description &&
{description}
} -
- )} -
- ); - } - - return ( -
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`} - > -
- -
- {(preset?.showTitle !== false || preset?.showDescription !== false) && ( -
- {preset?.showTitle !== false && title && ( -

- {title} -

- )} - {preset?.showDescription !== false && description && ( -

{description}

- )} -
- )} -
- ); - } - } - - if (normalizedType === MEDIA_TYPES.PLACE_SEARCH) { - return ( -
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`} - > -
- -
- {(preset?.showTitle !== false || preset?.showDescription !== false) && ( -
- {preset?.showTitle !== false && title && ( -

- {title} -

- )} - {meta?.placeType && ( -

{meta.placeType}

- )} - {preset?.showDescription !== false && description && ( -

{description}

- )} -
- )} -
- ); - } - - if (normalizedType === MEDIA_TYPES.PAGE || - normalizedType === MEDIA_TYPES.PAGE_EXTERNAL || - normalizedType === 'page-vfs-file' || - normalizedType === 'page-vfs-folder') { - return ( - 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 ( - - ); -}; - -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 = ({ + 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 ( +
+ +
+ ); + } + + if (normalizedType === MEDIA_TYPES.VIDEO_INTERN || normalizedType === MEDIA_TYPES.VIDEO_EXTERN) { + return ( + 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 ( +
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" + > +
+ + {title} +
+ {showContent && ( +
+
+ + {title} +
+ {description &&
{description}
} +
+ )} +
+ ); + } + + return ( +
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`} + > +
+ +
+ {(preset?.showTitle !== false || preset?.showDescription !== false) && ( +
+ {preset?.showTitle !== false && title && ( +

+ {title} +

+ )} + {preset?.showDescription !== false && description && ( +

{description}

+ )} +
+ )} +
+ ); + } + } + + if (normalizedType === MEDIA_TYPES.PLACE_SEARCH) { + return ( +
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`} + > +
+ +
+ {(preset?.showTitle !== false || preset?.showDescription !== false) && ( +
+ {preset?.showTitle !== false && title && ( +

+ {title} +

+ )} + {meta?.placeType && ( +

{meta.placeType}

+ )} + {preset?.showDescription !== false && description && ( +

{description}

+ )} +
+ )} +
+ ); + } + + if (normalizedType === MEDIA_TYPES.PAGE || + normalizedType === MEDIA_TYPES.PAGE_EXTERNAL || + normalizedType === 'page-vfs-file' || + normalizedType === 'page-vfs-folder') { + return ( + 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 ( + + ); +}; + +export default MediaCard; diff --git a/packages/ui/src/components/PhotoCard.tsx b/packages/ui/src/components/PhotoCard.tsx index 676dab9e..55e41853 100644 --- a/packages/ui/src/components/PhotoCard.tsx +++ b/packages/ui/src/components/PhotoCard.tsx @@ -1,629 +1,631 @@ -import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers, ExternalLink } from "lucide-react"; -import type { CardPreset } from "@/modules/pages/PageCard"; -import { Button } from "@/components/ui/button"; -import { useAuth } from "@/hooks/useAuth"; -import { toast } from "sonner"; -import React, { useState, useEffect } from "react"; -import MarkdownRenderer from "@/components/MarkdownRenderer"; -const EditImageModal = React.lazy(() => import("@/components/EditImageModal")); -const ImageLightbox = React.lazy(() => import("@/components/ImageLightbox")); -import MagicWizardButton from "@/components/MagicWizardButton"; -import { downloadImage, generateFilename } from "@/utils/downloadUtils"; - - -import { useNavigate } from "react-router-dom"; -import ResponsiveImage from "@/components/ResponsiveImage"; -import { T, translate } from "@/i18n"; -import { isLikelyFilename, formatDate } from "@/utils/textUtils"; -import UserAvatarBlock from "@/components/UserAvatarBlock"; -import { toggleLike, deletePicture, uploadFileToStorage, createPicture, updateStorageFile } from "@/modules/posts/client-pictures"; - -interface PhotoCardProps { - pictureId: string; - image: string; - title: string; - author: string; - authorId: string; - likes: number; - comments: number; - isLiked?: boolean; - description?: string | null; - onClick?: (pictureId: string) => void; - onLike?: () => void; - onDelete?: () => void; - isVid?: boolean; - onEdit?: (pictureId: string) => void; - createdAt?: string; - authorAvatarUrl?: string | null; - showContent?: boolean; - showHeader?: boolean; - overlayMode?: 'hover' | 'always'; - responsive?: any; - variant?: 'grid' | 'feed'; - apiUrl?: string; - versionCount?: number; - isExternal?: boolean; - imageFit?: 'contain' | 'cover'; - className?: string; // Allow custom classes from parent - preset?: CardPreset; - showAuthor?: boolean; - showActions?: boolean; - showTitle?: boolean; - showDescription?: boolean; - /** Place search: types[0], shown above description */ - placeTypeLabel?: string | null; -} - -const PhotoCard = ({ - pictureId, - image, - title, - author, - authorId, - likes, - comments, - isLiked = false, - description, - onClick, - onLike, - onDelete, - isVid, - onEdit, - createdAt, - authorAvatarUrl, - showContent = true, - showHeader = true, - overlayMode = 'hover', - responsive, - variant = 'grid', - apiUrl, - versionCount, - isExternal = false, - imageFit = 'contain', - className, - preset, - showAuthor = true, - showActions = true, - showTitle = true, - showDescription = true, - placeTypeLabel -}: PhotoCardProps) => { - const { user } = useAuth(); - const navigate = useNavigate(); - - const [localIsLiked, setLocalIsLiked] = useState(isLiked); - const [localLikes, setLocalLikes] = useState(likes); - const [showEditModal, setShowEditModal] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [showLightbox, setShowLightbox] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); - const [isPublishing, setIsPublishing] = useState(false); - - const [generatedImageUrl, setGeneratedImageUrl] = useState(null); - - const isOwner = user?.id === authorId; - - // Fetch version count for owners only - const [localVersionCount, setLocalVersionCount] = useState(versionCount || 0); - - // Sync prop to state if needed, or just use prop. - // If we want to allow local updates (e.g. after adding a version), we can keep state. - useEffect(() => { - if (versionCount !== undefined) { - setLocalVersionCount(versionCount); - } - }, [versionCount]); - const handleLike = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (isExternal) return; - - if (!user) { - toast.error(translate('Please sign in to like pictures')); - return; - } - - try { - const nowLiked = await toggleLike(user.id, pictureId, localIsLiked); - setLocalIsLiked(nowLiked); - setLocalLikes(prev => nowLiked ? prev + 1 : prev - 1); - - onLike?.(); - } catch (error) { - console.error('Error toggling like:', error); - toast.error(translate('Failed to update like')); - } - }; - const handleDelete = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (isExternal) return; - - if (!user || !isOwner) { - toast.error(translate('You can only delete your own images')); - return; - } - - if (!confirm(translate('Are you sure you want to delete this image? This action cannot be undone.'))) { - return; - } - - setIsDeleting(true); - try { - await deletePicture(pictureId); - - toast.success(translate('Image deleted successfully')); - onDelete?.(); // Trigger refresh of the parent component - } catch (error) { - console.error('Error deleting image:', error); - toast.error(translate('Failed to delete image')); - } finally { - setIsDeleting(false); - } - }; - - const handleDownload = async () => { - try { - const filename = generateFilename(title); - await downloadImage(image, filename); - toast.success(translate('Image downloaded successfully')); - } catch (error) { - console.error('Error downloading image:', error); - toast.error(translate('Failed to download image')); - } - }; - - const handleLightboxOpen = () => { - setShowLightbox(true); - }; - - - - const handlePromptSubmit = async (prompt: string) => { - if (!prompt.trim()) { - toast.error(translate('Please enter a prompt')); - return; - } - - setIsGenerating(true); - try { - // Convert image URL to File for API - const response = await fetch(image); - const blob = await response.blob(); - const file = new File([blob], title || 'image.png', { - type: blob.type || 'image/png' - }); - - const { editImage } = await import("@/image-api"); - const result = await editImage(prompt, [file]); - - if (result) { - // Convert ArrayBuffer to base64 data URL - const uint8Array = new Uint8Array(result.imageData); - const imageBlob = new Blob([uint8Array], { type: 'image/png' }); - const reader = new FileReader(); - - reader.onload = () => { - const dataUrl = reader.result as string; - setGeneratedImageUrl(dataUrl); - toast.success(translate('Image generated successfully!')); - }; - - reader.readAsDataURL(imageBlob); - } - } catch (error) { - console.error('Error generating image:', error); - toast.error(translate('Failed to generate image. Please check your Google API key.')); - } finally { - setIsGenerating(false); - } - }; - - const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => { - if (isExternal) { - toast.error(translate('Cannot publish external images')); - return; - } - - if (!user) { - toast.error(translate('Please sign in to publish images')); - return; - } - - setIsPublishing(true); - try { - // Convert data URL to blob for upload - const response = await fetch(imageUrl); - const blob = await response.blob(); - - if (option === 'overwrite') { - // Overwrite the existing image - const currentImageUrl = image; - if (currentImageUrl.includes('supabase.co/storage/')) { - const urlParts = currentImageUrl.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const bucketPath = `${authorId}/${fileName}`; - - await updateStorageFile(bucketPath, blob); - - toast.success(translate('Image updated successfully!')); - } else { - toast.error(translate('Cannot overwrite this image')); - return; - } - } else { - // Create new image - const publicUrl = await uploadFileToStorage(user.id, blob); - - await createPicture({ - title: newTitle, - description: description || translate('Generated from') + `: ${title}`, - image_url: publicUrl, - user_id: user.id - } as any); - - toast.success(translate('Image published to gallery!')); - } - - setShowLightbox(false); - setGeneratedImageUrl(null); - - // Trigger refresh if available - onLike?.(); - } catch (error) { - console.error('Error publishing image:', error); - toast.error(translate('Failed to publish image')); - } finally { - setIsPublishing(false); - } - }; - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(pictureId); - }; - - const handleCardClick = (e: React.MouseEvent) => { - if (isVid) { - handleLightboxOpen(); - } else { - handleClick(e); - } - }; - // console.log(preset, variant); - return ( -
- {/* Image */} -
- - {/* Helper Badge for External Images */} - {isExternal && ( -
- - External -
- )} -
- - - {variant === 'grid' && (title || description || placeTypeLabel) && ( -
- {showTitle && title && !isLikelyFilename(title) && ( -

{title}

- )} - {placeTypeLabel && ( -

{placeTypeLabel}

- )} - {showDescription && description && ( -

{description}

- )} -
- )} - - {/* TESTING: Entire desktop hover overlay disabled */} - {false && showContent && variant === 'grid' && ( -
-
-
- {showHeader && showAuthor && ( -
- -
- )} -
- {showActions && !isExternal && ( - <> - - {localLikes > 0 && {localLikes}} - - - {comments} - - )} - - {isOwner && !isExternal && ( - <> - - - - - {localVersionCount > 1 && ( -
- - {localVersionCount} -
- )} - - )} -
-
- - {showTitle && !isLikelyFilename(title) &&

{title}

} - {showDescription && description && ( -
- -
- )} - - {createdAt && ( -
- {formatDate(createdAt)} -
- )} - - {showActions && ( -
- - - - {!isExternal && ( - - )} -
- )} -
-
- )} - - {/* Mobile/Feed Content - always visible below image */} - {showContent && (variant === 'feed' || (variant === 'grid' && true)) && ( -
- {/* Row 1: User Avatar (Left) + Actions (Right) */} -
- {/* User Avatar Block */} - {showAuthor && ( - - )} - - {/* Actions */} - {showActions && ( -
- {!isExternal && ( - <> - - {localLikes > 0 && ( - {localLikes} - )} - - - {comments > 0 && ( - {comments} - )} - - )} - - - - {!isExternal && ( - - )} - {isOwner && !isExternal && ( - - )} -
- )} -
- - {/* Likes */} - - - {/* Caption / Description section */} -
- {showTitle && (!isLikelyFilename(title) && title) && ( -
{title}
- )} - - {placeTypeLabel && ( -
{placeTypeLabel}
- )} - - {showDescription && description && ( -
- -
- )} - - {createdAt && ( -
- {formatDate(createdAt)} -
- )} -
-
- )} - - {showEditModal && !isExternal && ( - { - setShowEditModal(false); - onLike?.(); // Trigger refresh - }} - /> - )} - - { - setShowLightbox(false); - setGeneratedImageUrl(null); - }} - imageUrl={generatedImageUrl || image} - imageTitle={title} - onPromptSubmit={handlePromptSubmit} - onPublish={handlePublish} - isGenerating={isGenerating} - isPublishing={isPublishing} - showPrompt={!isExternal} - showPublish={!!generatedImageUrl && !isExternal} - generatedImageUrl={generatedImageUrl || undefined} - /> -
- ); -}; - -export default PhotoCard; +import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers, ExternalLink } from "lucide-react"; +import type { CardPreset } from "@/modules/pages/PageCard"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import React, { useState, useEffect } from "react"; +import MarkdownRenderer from "@/components/MarkdownRenderer"; +const EditImageModal = React.lazy(() => import("@/components/EditImageModal")); +const ImageLightbox = React.lazy(() => import("@/components/ImageLightbox")); +import MagicWizardButton from "@/components/MagicWizardButton"; +import { downloadImage, generateFilename } from "@/utils/downloadUtils"; + + +import { useNavigate } from "react-router-dom"; +import ResponsiveImage from "@/components/ResponsiveImage"; +import { T, translate } from "@/i18n"; +import { isLikelyFilename, formatDate } from "@/utils/textUtils"; +import UserAvatarBlock from "@/components/UserAvatarBlock"; +import { toggleLike, deletePicture, uploadFileToStorage, createPicture, updateStorageFile } from "@/modules/posts/client-pictures"; + +interface PhotoCardProps { + pictureId: string; + image: string; + title: string; + author: string; + authorId: string; + likes: number; + comments: number; + isLiked?: boolean; + description?: string | null; + onClick?: (pictureId: string) => void; + onLike?: () => void; + onDelete?: () => void; + isVid?: boolean; + onEdit?: (pictureId: string) => void; + createdAt?: string; + authorAvatarUrl?: string | null; + showContent?: boolean; + showHeader?: boolean; + overlayMode?: 'hover' | 'always'; + responsive?: any; + variant?: 'grid' | 'feed'; + apiUrl?: string; + versionCount?: number; + isExternal?: boolean; + imageFit?: 'contain' | 'cover'; + className?: string; // Allow custom classes from parent + preset?: CardPreset; + showAuthor?: boolean; + showActions?: boolean; + showTitle?: boolean; + showDescription?: boolean; + /** Place search: types[0], shown above description */ + placeTypeLabel?: string | null; +} + +const PhotoCard = ({ + pictureId, + image, + title, + author, + authorId, + likes, + comments, + isLiked = false, + description, + onClick, + onLike, + onDelete, + isVid, + onEdit, + createdAt, + authorAvatarUrl, + showContent = true, + showHeader = true, + overlayMode = 'hover', + responsive, + variant = 'grid', + apiUrl, + versionCount, + isExternal = false, + imageFit = 'contain', + className, + preset, + showAuthor = true, + showActions = true, + showTitle, + showDescription, + placeTypeLabel +}: PhotoCardProps) => { + const { user } = useAuth(); + const titleVisible = (showTitle ?? preset?.showTitle) !== false; + const descriptionVisible = (showDescription ?? preset?.showDescription) !== false; + const navigate = useNavigate(); + + const [localIsLiked, setLocalIsLiked] = useState(isLiked); + const [localLikes, setLocalLikes] = useState(likes); + const [showEditModal, setShowEditModal] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [showLightbox, setShowLightbox] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + + const [generatedImageUrl, setGeneratedImageUrl] = useState(null); + + const isOwner = user?.id === authorId; + + // Fetch version count for owners only + const [localVersionCount, setLocalVersionCount] = useState(versionCount || 0); + + // Sync prop to state if needed, or just use prop. + // If we want to allow local updates (e.g. after adding a version), we can keep state. + useEffect(() => { + if (versionCount !== undefined) { + setLocalVersionCount(versionCount); + } + }, [versionCount]); + const handleLike = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (isExternal) return; + + if (!user) { + toast.error(translate('Please sign in to like pictures')); + return; + } + + try { + const nowLiked = await toggleLike(user.id, pictureId, localIsLiked); + setLocalIsLiked(nowLiked); + setLocalLikes(prev => nowLiked ? prev + 1 : prev - 1); + + onLike?.(); + } catch (error) { + console.error('Error toggling like:', error); + toast.error(translate('Failed to update like')); + } + }; + const handleDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (isExternal) return; + + if (!user || !isOwner) { + toast.error(translate('You can only delete your own images')); + return; + } + + if (!confirm(translate('Are you sure you want to delete this image? This action cannot be undone.'))) { + return; + } + + setIsDeleting(true); + try { + await deletePicture(pictureId); + + toast.success(translate('Image deleted successfully')); + onDelete?.(); // Trigger refresh of the parent component + } catch (error) { + console.error('Error deleting image:', error); + toast.error(translate('Failed to delete image')); + } finally { + setIsDeleting(false); + } + }; + + const handleDownload = async () => { + try { + const filename = generateFilename(title); + await downloadImage(image, filename); + toast.success(translate('Image downloaded successfully')); + } catch (error) { + console.error('Error downloading image:', error); + toast.error(translate('Failed to download image')); + } + }; + + const handleLightboxOpen = () => { + setShowLightbox(true); + }; + + + + const handlePromptSubmit = async (prompt: string) => { + if (!prompt.trim()) { + toast.error(translate('Please enter a prompt')); + return; + } + + setIsGenerating(true); + try { + // Convert image URL to File for API + const response = await fetch(image); + const blob = await response.blob(); + const file = new File([blob], title || 'image.png', { + type: blob.type || 'image/png' + }); + + const { editImage } = await import("@/image-api"); + const result = await editImage(prompt, [file]); + + if (result) { + // Convert ArrayBuffer to base64 data URL + const uint8Array = new Uint8Array(result.imageData); + const imageBlob = new Blob([uint8Array], { type: 'image/png' }); + const reader = new FileReader(); + + reader.onload = () => { + const dataUrl = reader.result as string; + setGeneratedImageUrl(dataUrl); + toast.success(translate('Image generated successfully!')); + }; + + reader.readAsDataURL(imageBlob); + } + } catch (error) { + console.error('Error generating image:', error); + toast.error(translate('Failed to generate image. Please check your Google API key.')); + } finally { + setIsGenerating(false); + } + }; + + const handlePublish = async (option: 'overwrite' | 'new', imageUrl: string, newTitle: string, description?: string) => { + if (isExternal) { + toast.error(translate('Cannot publish external images')); + return; + } + + if (!user) { + toast.error(translate('Please sign in to publish images')); + return; + } + + setIsPublishing(true); + try { + // Convert data URL to blob for upload + const response = await fetch(imageUrl); + const blob = await response.blob(); + + if (option === 'overwrite') { + // Overwrite the existing image + const currentImageUrl = image; + if (currentImageUrl.includes('supabase.co/storage/')) { + const urlParts = currentImageUrl.split('/'); + const fileName = urlParts[urlParts.length - 1]; + const bucketPath = `${authorId}/${fileName}`; + + await updateStorageFile(bucketPath, blob); + + toast.success(translate('Image updated successfully!')); + } else { + toast.error(translate('Cannot overwrite this image')); + return; + } + } else { + // Create new image + const publicUrl = await uploadFileToStorage(user.id, blob); + + await createPicture({ + title: newTitle, + description: description || translate('Generated from') + `: ${title}`, + image_url: publicUrl, + user_id: user.id + } as any); + + toast.success(translate('Image published to gallery!')); + } + + setShowLightbox(false); + setGeneratedImageUrl(null); + + // Trigger refresh if available + onLike?.(); + } catch (error) { + console.error('Error publishing image:', error); + toast.error(translate('Failed to publish image')); + } finally { + setIsPublishing(false); + } + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(pictureId); + }; + + const handleCardClick = (e: React.MouseEvent) => { + if (isVid) { + handleLightboxOpen(); + } else { + handleClick(e); + } + }; + // console.log(preset, variant); + return ( +
+ {/* Image */} +
+ + {/* Helper Badge for External Images */} + {isExternal && ( +
+ + External +
+ )} +
+ + + {variant === 'grid' && (title || description || placeTypeLabel) && ( +
+ {titleVisible && title && !isLikelyFilename(title) && ( +

{title}

+ )} + {placeTypeLabel && ( +

{placeTypeLabel}

+ )} + {descriptionVisible && description && ( +

{description}

+ )} +
+ )} + + {/* TESTING: Entire desktop hover overlay disabled */} + {false && showContent && variant === 'grid' && ( +
+
+
+ {showHeader && showAuthor && ( +
+ +
+ )} +
+ {showActions && !isExternal && ( + <> + + {localLikes > 0 && {localLikes}} + + + {comments} + + )} + + {isOwner && !isExternal && ( + <> + + + + + {localVersionCount > 1 && ( +
+ + {localVersionCount} +
+ )} + + )} +
+
+ + {titleVisible && !isLikelyFilename(title) &&

{title}

} + {descriptionVisible && description && ( +
+ +
+ )} + + {createdAt && ( +
+ {formatDate(createdAt)} +
+ )} + + {showActions && ( +
+ + + + {!isExternal && ( + + )} +
+ )} +
+
+ )} + + {/* Mobile/Feed Content - always visible below image */} + {showContent && (variant === 'feed' || (variant === 'grid' && true)) && ( +
+ {/* Row 1: User Avatar (Left) + Actions (Right) */} +
+ {/* User Avatar Block */} + {showAuthor && ( + + )} + + {/* Actions */} + {showActions && ( +
+ {!isExternal && ( + <> + + {localLikes > 0 && ( + {localLikes} + )} + + + {comments > 0 && ( + {comments} + )} + + )} + + + + {!isExternal && ( + + )} + {isOwner && !isExternal && ( + + )} +
+ )} +
+ + {/* Likes */} + + + {/* Caption / Description section */} +
+ {titleVisible && (!isLikelyFilename(title) && title) && ( +
{title}
+ )} + + {placeTypeLabel && ( +
{placeTypeLabel}
+ )} + + {descriptionVisible && description && ( +
+ +
+ )} + + {createdAt && ( +
+ {formatDate(createdAt)} +
+ )} +
+
+ )} + + {showEditModal && !isExternal && ( + { + setShowEditModal(false); + onLike?.(); // Trigger refresh + }} + /> + )} + + { + setShowLightbox(false); + setGeneratedImageUrl(null); + }} + imageUrl={generatedImageUrl || image} + imageTitle={title} + onPromptSubmit={handlePromptSubmit} + onPublish={handlePublish} + isGenerating={isGenerating} + isPublishing={isPublishing} + showPrompt={!isExternal} + showPublish={!!generatedImageUrl && !isExternal} + generatedImageUrl={generatedImageUrl || undefined} + /> +
+ ); +}; + +export default PhotoCard; diff --git a/packages/ui/src/components/VideoCard.tsx b/packages/ui/src/components/VideoCard.tsx index d065662e..606baa5a 100644 --- a/packages/ui/src/components/VideoCard.tsx +++ b/packages/ui/src/components/VideoCard.tsx @@ -1,692 +1,697 @@ -import { Heart, Download, Share2, MessageCircle, Edit3, Trash2, Layers, Loader2, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { deletePictures } from "@/modules/posts/client-pictures"; -import { useAuth } from "@/hooks/useAuth"; -import { toast } from "sonner"; -import React, { useState, useEffect, useRef } from "react"; -import MarkdownRenderer from "@/components/MarkdownRenderer"; - -import { useNavigate, useLocation } from "react-router-dom"; -import { T, translate } from "@/i18n"; -import type { MuxResolution } from "@/types"; -import EditVideoModal from "@/components/EditVideoModal"; -import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry"; -import UserAvatarBlock from "@/components/UserAvatarBlock"; -import { formatDate, isLikelyFilename } from "@/utils/textUtils"; - -// import { -// MediaPlayer, MediaProvider, type MediaPlayerInstance -// } from '@vidstack/react'; -import type { MediaPlayerInstance } from '@vidstack/react'; -import { serverUrl } from "@/lib/db"; - -// Import Vidstack styles -// import '@vidstack/react/player/styles/default/theme.css'; -// import '@vidstack/react/player/styles/default/layouts/video.css'; - -// Lazy load Vidstack implementation -const VidstackPlayer = React.lazy(() => import('../player/components/VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl }))); - -interface VideoCardProps { - videoId: string; - videoUrl: string; - thumbnailUrl?: string; - title: string; - author: string; - authorId: string; - likes: number; - comments: number; - isLiked?: boolean; - description?: string | null; - onClick?: (videoId: string) => void; - onLike?: () => void; - onDelete?: () => void; - maxResolution?: MuxResolution; - authorAvatarUrl?: string | null; - showContent?: boolean; - - created_at?: string; - job?: any; - variant?: 'grid' | 'feed'; - showPlayButton?: boolean; - apiUrl?: string; - showTitle?: boolean; - showDescription?: boolean; - showAuthor?: boolean; - showActions?: boolean; -} - -const VideoCard = ({ - videoId, - videoUrl, - thumbnailUrl, - title, - author, - authorId, - likes, - comments, - isLiked = false, - description, - onClick, - onLike, - onDelete, - maxResolution = '270p', - authorAvatarUrl, - showContent = true, - showPlayButton = false, - - created_at, - job, - variant = 'grid', - apiUrl, - showTitle = true, - showDescription = true, - showAuthor = true, - showActions = true -}: VideoCardProps) => { - const { user } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - const [localIsLiked, setLocalIsLiked] = useState(isLiked); - const [localLikes, setLocalLikes] = useState(likes); - const [isDeleting, setIsDeleting] = useState(false); - const [versionCount, setVersionCount] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const player = useRef(null); - - // Stop playback on navigation & Cleanup - useEffect(() => { - const handleNavigation = () => { - setIsPlaying(false); - player.current?.pause(); - }; - - handleNavigation(); - - return () => { - player.current?.pause(); - }; - }, [location.pathname]); - const [processingStatus, setProcessingStatus] = useState<'active' | 'completed' | 'failed' | 'unknown'>('unknown'); - const [progress, setProgress] = useState(0); - const [videoMeta, setVideoMeta] = useState(null); - - const isOwner = user?.id === authorId; - const mediaType = detectMediaType(videoUrl); - const isInternalVideo = mediaType === MEDIA_TYPES.VIDEO_INTERN; - const isExternalVideo = mediaType === MEDIA_TYPES.VIDEO_EXTERN; - - // Normalize URLs: DB may store relative paths or old localhost URLs. - // Extracts the /api/ path and prepends the configured server base. - const rewriteUrl = (url: string): string => { - if (!url) return url; - const serverBase = serverUrl; - if (!serverBase) return url; - const apiIdx = url.indexOf('/api/'); - if (apiIdx !== -1) return `${serverBase}${url.slice(apiIdx)}`; - return url; - }; - - const normalizedVideoUrl = isInternalVideo ? rewriteUrl(videoUrl) : videoUrl; - const normalizedThumbnailUrl = isInternalVideo ? rewriteUrl(thumbnailUrl || '') : thumbnailUrl; - - // Handle poster URL based on media type - const posterUrl = (() => { - if (!normalizedThumbnailUrl) return undefined; - - if (isInternalVideo || isExternalVideo) { - return normalizedThumbnailUrl; // Use direct thumbnail for internal videos - } - - // Default to Mux behavior - return `${normalizedThumbnailUrl}?width=640&height=640&fit_mode=smartcrop&time=0`; - })(); - - // Add max_resolution query parameter to video URL for bandwidth optimization - // See: https://www.mux.com/docs/guides/control-playback-resolution - const getVideoUrlWithResolution = (url: string) => { - if (isInternalVideo || isExternalVideo) return url; // Internal videos handle quality differently (via HLS/presets in future) - - try { - const urlObj = new URL(url); - urlObj.searchParams.set('max_resolution', maxResolution); - return urlObj.toString(); - } catch { - // If URL parsing fails, append as query string - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}max_resolution=${maxResolution}`; - } - }; - - const playbackUrl = getVideoUrlWithResolution(normalizedVideoUrl); - - // Fetch version count for owners only - useEffect(() => { - const fetchVersionCount = async () => { - if (!isOwner || !user) return; - return; - }; - - fetchVersionCount(); - - }, [videoId, isOwner, user]); - - // Handle Video Processing Status (SSE) - useEffect(() => { - if (!isInternalVideo) return; - - // 1. Use verified job data from server if available (e.g. from Feed) - if (job) { - if (job.status === 'completed') { - setProcessingStatus('completed'); - // If we have a verified resultUrl, we might want to use it? - // But the parent component passes `videoUrl`, so we assume parent updated it or logic below handles it. - return; - } - if (job.status === 'failed') { - setProcessingStatus('failed'); - return; - } - // If active, we fall through to start SSE below, but init state first - setProcessingStatus('active'); - setProgress(job.progress || 0); - } - - // Extract Job ID from URL - // Format: .../api/videos/jobs/:jobId/... (regex: /api/videos/jobs/([^/]+)\//) - const match = normalizedVideoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//) || (job ? [null, job.id] : null); - if (!match) return; - - const jobId = match[1]; - const baseUrl = serverUrl || ''; - - let eventSource: EventSource | null = null; - let isMounted = true; - - const checkStatusAndConnect = async () => { - if (!jobId) return; - try { - const res = await fetch(`${baseUrl}/api/videos/jobs/${jobId}`); - if (!res.ok) throw new Error('Failed to fetch job'); - const data = await res.json(); - - if (!isMounted) return; - - if (data.status === 'completed') { - setProcessingStatus('completed'); - return; - } else if (data.status === 'failed') { - setProcessingStatus('failed'); - return; - } - - // Only connect SSE if still active/created/waiting - setProcessingStatus('active'); - const sseUrl = `${baseUrl}/api/videos/jobs/${jobId}/progress`; - eventSource = new EventSource(sseUrl); - - eventSource.addEventListener('progress', (e) => { - if (!isMounted) return; - try { - const data = JSON.parse((e as MessageEvent).data); - if (data.progress !== undefined) { - setProgress(Math.round(data.progress)); - } - if (data.status) { - if (data.status === 'completed') { - setProcessingStatus('completed'); - eventSource?.close(); - } else if (data.status === 'failed') { - setProcessingStatus('failed'); - eventSource?.close(); - } else { - setProcessingStatus('active'); - } - } - } catch (err) { - console.error('SSE Parse Error', err); - } - }); - - eventSource.onerror = (e) => { - eventSource?.close(); - // Fallback check handled by initial check logic or user refresh - // But we can retry once - }; - - } catch (error) { - console.error('Error checking video status:', error); - if (isMounted) setProcessingStatus('unknown'); - } - }; - - checkStatusAndConnect(); - - return () => { - isMounted = false; - if (eventSource) { - eventSource.close(); - } - }; - }, [isInternalVideo, normalizedVideoUrl, job]); - - const handleCancelProcessing = async (e: React.MouseEvent) => { - e.stopPropagation(); - if (!confirm('Cancel this upload?')) return; - - const match = normalizedVideoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//); - if (!match) return; - - const jobId = match[1]; - const baseUrl = serverUrl || ''; - try { - await fetch(`${baseUrl}/api/videos/jobs/${jobId}`, { - method: 'DELETE' - }); - toast.info(translate('Upload cancelled')); - // Trigger delete to remove from UI - onDelete?.(); - } catch (err) { - console.error('Failed to cancel', err); - toast.error(translate('Failed to cancel upload')); - } - }; - - - const handleLike = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (!user) { - toast.error(translate('Please sign in to like videos')); - return; - } - - try { - if (localIsLiked) { - // Unlike - need to implement video likes table - toast.info('Video likes coming soon'); - } else { - // Like - need to implement video likes table - toast.info('Video likes coming soon'); - } - - onLike?.(); - } catch (error) { - console.error('Error toggling like:', error); - toast.error(translate('Failed to update like')); - } - }; - - const handleDelete = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (!user || !isOwner) { - toast.error(translate('You can only delete your own videos')); - return; - } - - if (!confirm(translate('Are you sure you want to delete this video? This action cannot be undone.'))) { - return; - } - - setIsDeleting(true); - try { - // Delete via API (handles DB + storage cleanup internally) - await deletePictures([videoId]); - - toast.success(translate('Video deleted successfully')); - onDelete?.(); // Trigger refresh of the parent component - } catch (error) { - console.error('Error deleting video:', error); - toast.error(translate('Failed to delete video')); - } finally { - setIsDeleting(false); - } - }; - - const handleDownload = async () => { - try { - const link = document.createElement('a'); - link.href = videoUrl; - link.download = `${title}.mp4`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - toast.success(translate('Video download started')); - } catch (error) { - console.error('Error downloading video:', error); - toast.error(translate('Failed to download video')); - } - }; - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(videoId); - }; - - // Handle global stop-video event - useEffect(() => { - const handleStopVideo = (e: Event) => { - const customEvent = e as CustomEvent; - if (customEvent.detail?.sourceId !== videoId && isPlaying) { - setIsPlaying(false); - player.current?.pause(); - } - }; - - window.addEventListener('stop-video', handleStopVideo); - return () => window.removeEventListener('stop-video', handleStopVideo); - }, [isPlaying, videoId]); - - const handlePlayClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Stop other videos - window.dispatchEvent(new CustomEvent('stop-video', { detail: { sourceId: videoId } })); - - setIsPlaying(true); - }; - - return ( -
- {/* Video Player - usage square aspect to match PhotoCard unless variant is feed */} -
- {!isPlaying ? ( - // Show thumbnail with play button overlay - <> - {title} - {/* Processing Overlay */} - {isInternalVideo && processingStatus !== 'completed' && processingStatus !== 'unknown' && ( -
- {processingStatus === 'failed' ? ( -
- Processing Failed -
- ) : ( -
- - - Processing {progress}% - -
- )} -
- )} - - {/* Play Button Overlay */} - {showPlayButton && (!isInternalVideo || processingStatus === 'completed' || processingStatus === 'unknown') && ( - - )} - - ) : ( - // Show MediaPlayer when playing -
}> - - - )} -
- - {/* TESTING: Entire desktop hover overlay disabled */} - {false && showContent && variant === 'grid' && !isPlaying && ( -
-
-
-
- -
-
- - {localLikes} - - - {comments} - - {isOwner && ( - <> - - - - - {versionCount > 1 && ( -
- - {versionCount} -
- )} - - )} -
-
- - {/* TESTING: hover title+description disabled -

{title}

- {description && ( -
- -
- )} - */} - -
- - -
-
-
- )} - - {/* Mobile/Feed Content - always visible below video */} - {showContent && (variant === 'feed' || (variant === 'grid' && true)) && ( -
- {/* Row 1: Avatar + Actions */} -
- {showAuthor && ( - - )} - {showActions && ( -
- - {localLikes > 0 && ( - {localLikes} - )} - - - {comments > 0 && ( - {comments} - )} - - - - {isOwner && ( - <> - - - )} -
- )} -
- - {/* Likes */} - - - {/* Caption / Description section */} -
- {showTitle && (!isLikelyFilename(title) && title) && ( -
{title}
- )} - - {showDescription && description && ( -
- -
- )} - - {created_at && ( -
- {formatDate(created_at)} -
- )} -
-
- )} - - {showEditModal && ( - { - setShowEditModal(false); - onDelete?.(); // Trigger refresh - }} - /> - )} - - ); -}; - -export default VideoCard; +import { Heart, Download, Share2, MessageCircle, Edit3, Trash2, Layers, Loader2, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { deletePictures } from "@/modules/posts/client-pictures"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import React, { useState, useEffect, useRef } from "react"; +import MarkdownRenderer from "@/components/MarkdownRenderer"; + +import { useNavigate, useLocation } from "react-router-dom"; +import { T, translate } from "@/i18n"; +import type { MuxResolution } from "@/types"; +import EditVideoModal from "@/components/EditVideoModal"; +import { detectMediaType, MEDIA_TYPES } from "@/lib/mediaRegistry"; +import UserAvatarBlock from "@/components/UserAvatarBlock"; +import { formatDate, isLikelyFilename } from "@/utils/textUtils"; +import type { CardPreset } from '@/modules/pages/PageCard'; + +// import { +// MediaPlayer, MediaProvider, type MediaPlayerInstance +// } from '@vidstack/react'; +import type { MediaPlayerInstance } from '@vidstack/react'; +import { serverUrl } from "@/lib/db"; + +// Import Vidstack styles +// import '@vidstack/react/player/styles/default/theme.css'; +// import '@vidstack/react/player/styles/default/layouts/video.css'; + +// Lazy load Vidstack implementation +const VidstackPlayer = React.lazy(() => import('../player/components/VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl }))); + +interface VideoCardProps { + videoId: string; + videoUrl: string; + thumbnailUrl?: string; + title: string; + author: string; + authorId: string; + likes: number; + comments: number; + isLiked?: boolean; + description?: string | null; + onClick?: (videoId: string) => void; + onLike?: () => void; + onDelete?: () => void; + maxResolution?: MuxResolution; + authorAvatarUrl?: string | null; + showContent?: boolean; + + created_at?: string; + job?: any; + variant?: 'grid' | 'feed'; + showPlayButton?: boolean; + apiUrl?: string; + showTitle?: boolean; + showDescription?: boolean; + showAuthor?: boolean; + showActions?: boolean; + preset?: CardPreset; +} + +const VideoCard = ({ + videoId, + videoUrl, + thumbnailUrl, + title, + author, + authorId, + likes, + comments, + isLiked = false, + description, + onClick, + onLike, + onDelete, + maxResolution = '270p', + authorAvatarUrl, + showContent = true, + showPlayButton = false, + + created_at, + job, + variant = 'grid', + apiUrl, + showTitle, + showDescription, + showAuthor = true, + showActions = true, + preset +}: VideoCardProps) => { + const { user } = useAuth(); + const titleVisible = (showTitle ?? preset?.showTitle) !== false; + const descriptionVisible = (showDescription ?? preset?.showDescription) !== false; + const navigate = useNavigate(); + const location = useLocation(); + const [localIsLiked, setLocalIsLiked] = useState(isLiked); + const [localLikes, setLocalLikes] = useState(likes); + const [isDeleting, setIsDeleting] = useState(false); + const [versionCount, setVersionCount] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const player = useRef(null); + + // Stop playback on navigation & Cleanup + useEffect(() => { + const handleNavigation = () => { + setIsPlaying(false); + player.current?.pause(); + }; + + handleNavigation(); + + return () => { + player.current?.pause(); + }; + }, [location.pathname]); + const [processingStatus, setProcessingStatus] = useState<'active' | 'completed' | 'failed' | 'unknown'>('unknown'); + const [progress, setProgress] = useState(0); + const [videoMeta, setVideoMeta] = useState(null); + + const isOwner = user?.id === authorId; + const mediaType = detectMediaType(videoUrl); + const isInternalVideo = mediaType === MEDIA_TYPES.VIDEO_INTERN; + const isExternalVideo = mediaType === MEDIA_TYPES.VIDEO_EXTERN; + + // Normalize URLs: DB may store relative paths or old localhost URLs. + // Extracts the /api/ path and prepends the configured server base. + const rewriteUrl = (url: string): string => { + if (!url) return url; + const serverBase = serverUrl; + if (!serverBase) return url; + const apiIdx = url.indexOf('/api/'); + if (apiIdx !== -1) return `${serverBase}${url.slice(apiIdx)}`; + return url; + }; + + const normalizedVideoUrl = isInternalVideo ? rewriteUrl(videoUrl) : videoUrl; + const normalizedThumbnailUrl = isInternalVideo ? rewriteUrl(thumbnailUrl || '') : thumbnailUrl; + + // Handle poster URL based on media type + const posterUrl = (() => { + if (!normalizedThumbnailUrl) return undefined; + + if (isInternalVideo || isExternalVideo) { + return normalizedThumbnailUrl; // Use direct thumbnail for internal videos + } + + // Default to Mux behavior + return `${normalizedThumbnailUrl}?width=640&height=640&fit_mode=smartcrop&time=0`; + })(); + + // Add max_resolution query parameter to video URL for bandwidth optimization + // See: https://www.mux.com/docs/guides/control-playback-resolution + const getVideoUrlWithResolution = (url: string) => { + if (isInternalVideo || isExternalVideo) return url; // Internal videos handle quality differently (via HLS/presets in future) + + try { + const urlObj = new URL(url); + urlObj.searchParams.set('max_resolution', maxResolution); + return urlObj.toString(); + } catch { + // If URL parsing fails, append as query string + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}max_resolution=${maxResolution}`; + } + }; + + const playbackUrl = getVideoUrlWithResolution(normalizedVideoUrl); + + // Fetch version count for owners only + useEffect(() => { + const fetchVersionCount = async () => { + if (!isOwner || !user) return; + return; + }; + + fetchVersionCount(); + + }, [videoId, isOwner, user]); + + // Handle Video Processing Status (SSE) + useEffect(() => { + if (!isInternalVideo) return; + + // 1. Use verified job data from server if available (e.g. from Feed) + if (job) { + if (job.status === 'completed') { + setProcessingStatus('completed'); + // If we have a verified resultUrl, we might want to use it? + // But the parent component passes `videoUrl`, so we assume parent updated it or logic below handles it. + return; + } + if (job.status === 'failed') { + setProcessingStatus('failed'); + return; + } + // If active, we fall through to start SSE below, but init state first + setProcessingStatus('active'); + setProgress(job.progress || 0); + } + + // Extract Job ID from URL + // Format: .../api/videos/jobs/:jobId/... (regex: /api/videos/jobs/([^/]+)\//) + const match = normalizedVideoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//) || (job ? [null, job.id] : null); + if (!match) return; + + const jobId = match[1]; + const baseUrl = serverUrl || ''; + + let eventSource: EventSource | null = null; + let isMounted = true; + + const checkStatusAndConnect = async () => { + if (!jobId) return; + try { + const res = await fetch(`${baseUrl}/api/videos/jobs/${jobId}`); + if (!res.ok) throw new Error('Failed to fetch job'); + const data = await res.json(); + + if (!isMounted) return; + + if (data.status === 'completed') { + setProcessingStatus('completed'); + return; + } else if (data.status === 'failed') { + setProcessingStatus('failed'); + return; + } + + // Only connect SSE if still active/created/waiting + setProcessingStatus('active'); + const sseUrl = `${baseUrl}/api/videos/jobs/${jobId}/progress`; + eventSource = new EventSource(sseUrl); + + eventSource.addEventListener('progress', (e) => { + if (!isMounted) return; + try { + const data = JSON.parse((e as MessageEvent).data); + if (data.progress !== undefined) { + setProgress(Math.round(data.progress)); + } + if (data.status) { + if (data.status === 'completed') { + setProcessingStatus('completed'); + eventSource?.close(); + } else if (data.status === 'failed') { + setProcessingStatus('failed'); + eventSource?.close(); + } else { + setProcessingStatus('active'); + } + } + } catch (err) { + console.error('SSE Parse Error', err); + } + }); + + eventSource.onerror = (e) => { + eventSource?.close(); + // Fallback check handled by initial check logic or user refresh + // But we can retry once + }; + + } catch (error) { + console.error('Error checking video status:', error); + if (isMounted) setProcessingStatus('unknown'); + } + }; + + checkStatusAndConnect(); + + return () => { + isMounted = false; + if (eventSource) { + eventSource.close(); + } + }; + }, [isInternalVideo, normalizedVideoUrl, job]); + + const handleCancelProcessing = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!confirm('Cancel this upload?')) return; + + const match = normalizedVideoUrl.match(/\/api\/videos\/jobs\/([^\/]+)\//); + if (!match) return; + + const jobId = match[1]; + const baseUrl = serverUrl || ''; + try { + await fetch(`${baseUrl}/api/videos/jobs/${jobId}`, { + method: 'DELETE' + }); + toast.info(translate('Upload cancelled')); + // Trigger delete to remove from UI + onDelete?.(); + } catch (err) { + console.error('Failed to cancel', err); + toast.error(translate('Failed to cancel upload')); + } + }; + + + const handleLike = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!user) { + toast.error(translate('Please sign in to like videos')); + return; + } + + try { + if (localIsLiked) { + // Unlike - need to implement video likes table + toast.info('Video likes coming soon'); + } else { + // Like - need to implement video likes table + toast.info('Video likes coming soon'); + } + + onLike?.(); + } catch (error) { + console.error('Error toggling like:', error); + toast.error(translate('Failed to update like')); + } + }; + + const handleDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!user || !isOwner) { + toast.error(translate('You can only delete your own videos')); + return; + } + + if (!confirm(translate('Are you sure you want to delete this video? This action cannot be undone.'))) { + return; + } + + setIsDeleting(true); + try { + // Delete via API (handles DB + storage cleanup internally) + await deletePictures([videoId]); + + toast.success(translate('Video deleted successfully')); + onDelete?.(); // Trigger refresh of the parent component + } catch (error) { + console.error('Error deleting video:', error); + toast.error(translate('Failed to delete video')); + } finally { + setIsDeleting(false); + } + }; + + const handleDownload = async () => { + try { + const link = document.createElement('a'); + link.href = videoUrl; + link.download = `${title}.mp4`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast.success(translate('Video download started')); + } catch (error) { + console.error('Error downloading video:', error); + toast.error(translate('Failed to download video')); + } + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(videoId); + }; + + // Handle global stop-video event + useEffect(() => { + const handleStopVideo = (e: Event) => { + const customEvent = e as CustomEvent; + if (customEvent.detail?.sourceId !== videoId && isPlaying) { + setIsPlaying(false); + player.current?.pause(); + } + }; + + window.addEventListener('stop-video', handleStopVideo); + return () => window.removeEventListener('stop-video', handleStopVideo); + }, [isPlaying, videoId]); + + const handlePlayClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Stop other videos + window.dispatchEvent(new CustomEvent('stop-video', { detail: { sourceId: videoId } })); + + setIsPlaying(true); + }; + + return ( +
+ {/* Video Player - usage square aspect to match PhotoCard unless variant is feed */} +
+ {!isPlaying ? ( + // Show thumbnail with play button overlay + <> + {title} + {/* Processing Overlay */} + {isInternalVideo && processingStatus !== 'completed' && processingStatus !== 'unknown' && ( +
+ {processingStatus === 'failed' ? ( +
+ Processing Failed +
+ ) : ( +
+ + + Processing {progress}% + +
+ )} +
+ )} + + {/* Play Button Overlay */} + {showPlayButton && (!isInternalVideo || processingStatus === 'completed' || processingStatus === 'unknown') && ( + + )} + + ) : ( + // Show MediaPlayer when playing +
}> + + + )} +
+ + {/* TESTING: Entire desktop hover overlay disabled */} + {false && showContent && variant === 'grid' && !isPlaying && ( +
+
+
+
+ +
+
+ + {localLikes} + + + {comments} + + {isOwner && ( + <> + + + + + {versionCount > 1 && ( +
+ + {versionCount} +
+ )} + + )} +
+
+ + {/* TESTING: hover title+description disabled +

{title}

+ {description && ( +
+ +
+ )} + */} + +
+ + +
+
+
+ )} + + {/* Mobile/Feed Content - always visible below video */} + {showContent && (variant === 'feed' || (variant === 'grid' && true)) && ( +
+ {/* Row 1: Avatar + Actions */} +
+ {showAuthor && ( + + )} + {showActions && ( +
+ + {localLikes > 0 && ( + {localLikes} + )} + + + {comments > 0 && ( + {comments} + )} + + + + {isOwner && ( + <> + + + )} +
+ )} +
+ + {/* Likes */} + + + {/* Caption / Description section */} +
+ {titleVisible && (!isLikelyFilename(title) && title) && ( +
{title}
+ )} + + {descriptionVisible && description && ( +
+ +
+ )} + + {created_at && ( +
+ {formatDate(created_at)} +
+ )} +
+
+ )} + + {showEditModal && ( + { + setShowEditModal(false); + onDelete?.(); // Trigger refresh + }} + /> + )} + + ); +}; + +export default VideoCard; diff --git a/packages/ui/src/components/feed/FeedCard.tsx b/packages/ui/src/components/feed/FeedCard.tsx index 254dd167..86bcdc1c 100644 --- a/packages/ui/src/components/feed/FeedCard.tsx +++ b/packages/ui/src/components/feed/FeedCard.tsx @@ -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 = ({ - 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(initialLiked); - const [likeCount, setLikeCount] = useState(post.likes_count || 0); - const [lastTap, setLastTap] = useState(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 ( -
-
{ - 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"> -

{post.title}

- {post.description &&

{post.description}

} -
-
- ); - } - return null; - } - - return ( -
- {/* Header: Author Info */} -
- -
- {post.created_at ? formatDate(post.created_at) : ''} -
-
- - {/* Media Carousel */} -
- -
- - {/* Actions Bar */} -
- - {likeCount > 0 && ( - {likeCount} - )} - - -
- - {/* Caption: Title & Description */} -
- {showTitle && post.title && ( -
{post.title}
- )} - {showDescription && post.description && ( -
- -
- )} -
-
- ); -}; +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 = ({ + 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(initialLiked); + const [likeCount, setLikeCount] = useState(post.likes_count || 0); + const [lastTap, setLastTap] = useState(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 ( +
+
{ + 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"> +

{post.title}

+ {post.description &&

{post.description}

} +
+
+ ); + } + return null; + } + + return ( +
+ {/* Header: Author Info */} + {showAuthor && ( +
+ +
+ {post.created_at ? formatDate(post.created_at) : ''} +
+
+ )} + + {/* Media Carousel */} +
+ +
+ + {/* Actions Bar */} + {showSocial && ( +
+ + {likeCount > 0 && ( + {likeCount} + )} + + +
+ )} + + {/* Caption: Title & Description */} +
+ {showTitle && post.title && ( +
{post.title}
+ )} + {showDescription && post.description && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/components/feed/MobileFeed.tsx b/packages/ui/src/components/feed/MobileFeed.tsx index 4614c508..c12ab7fe 100644 --- a/packages/ui/src/components/feed/MobileFeed.tsx +++ b/packages/ui/src/components/feed/MobileFeed.tsx @@ -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 = ({ - 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 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 ( -
- -
- ); - } - - if (error) { - return ( -
- Failed to load feed. -
- ); - } - - if (posts.length === 0) { - return ( -
-

No posts yet.

-
- ); - } - - const isSearchTabAllOrFiles = source === 'search' && (!contentType || contentType === 'files'); - - const renderItems = () => { - if (!isSearchTabAllOrFiles) { - return posts.map((post, index) => ( - - )); - } - - 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(); - 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( -
- {group} -
- ); - elements.push( - ...groups.get(group)!.map((post: any, index: number) => ( - - )) - ); - } - } - - return elements; - }; - - return ( -
- {renderItems()} -
- ); -}; - -// 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 ( -
- -
- ); -}; - -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 = ({ + 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 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+ Failed to load feed. +
+ ); + } + + if (posts.length === 0) { + return ( +
+

No posts yet.

+
+ ); + } + + const isSearchTabAllOrFiles = source === 'search' && (!contentType || contentType === 'files'); + + const renderItems = () => { + if (!isSearchTabAllOrFiles) { + return posts.map((post, index) => ( + + )); + } + + 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(); + 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( +
+ {group} +
+ ); + elements.push( + ...groups.get(group)!.map((post: any, index: number) => ( + + )) + ); + } + } + + return elements; + }; + + return ( +
+ {renderItems()} +
+ ); +}; + +// 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 ( +
+ +
+ ); +}; + +export default MobileFeed; diff --git a/packages/ui/src/components/widgets/CategoryFeedWidget.tsx b/packages/ui/src/components/widgets/CategoryFeedWidget.tsx index e837ba2a..1bf72397 100644 --- a/packages/ui/src/components/widgets/CategoryFeedWidget.tsx +++ b/packages/ui/src/components/widgets/CategoryFeedWidget.tsx @@ -1,203 +1,218 @@ -import React, { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { fetchCategories, type Category } from '@/modules/categories/client-categories'; -import { useAppConfig } from '@/hooks/useSystemInfo'; -import HomeWidget, { type HomeWidgetProps } from '@/components/widgets/HomeWidget'; -import { CategoryPickerField } from '@/components/widgets/CategoryPickerField'; -import { T, translate } from '@/i18n'; -import { cn } from '@/lib/utils'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -export interface CategoryFeedWidgetProps extends HomeWidgetProps { - /** ID of the selected category */ - categoryId?: string; - /** Heading text displayed above the feed */ - heading?: string; - /** Heading level: h1–h4 */ - headingLevel?: 'h1' | 'h2' | 'h3' | 'h4'; - /** Content type filter: posts, pages, or pictures */ - filterType?: 'posts' | 'pages' | 'pictures'; - /** Widget system props */ - isEditMode?: boolean; - onPropsChange?: (props: Record) => 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 = ({ - 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 ( -
- {/* Heading text input */} -
- - onPropsChange?.({ heading: e.target.value })} - placeholder={translate('Section heading...')} - className="h-8 text-sm" - /> -
- - {/* Heading level selector */} -
- - -
- - {/* Content type filter */} -
- - -
- - {/* Category picker */} -
- - onPropsChange?.({ categoryId: id })} - /> -
-
- ); - } - - return null; - }; - - return ( -
- {renderHeading()} - -
- ); -}; - -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 = ({ + 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 ( +
+ {/* Heading text input */} +
+ + onPropsChange?.({ heading: e.target.value })} + placeholder={translate('Section heading...')} + className="h-8 text-sm" + /> +
+ + {/* Heading level selector */} +
+ + +
+ + {/* Content type filter */} +
+ + +
+ + {/* Category picker */} +
+ + onPropsChange?.({ categoryId: id })} + /> +
+ +
+ + onPropsChange?.({ showTitle: v })} + /> +
+
+ + onPropsChange?.({ showDescription: v })} + /> +
+
+ ); + } + + return null; + }; + + return ( +
+ {renderHeading()} + +
+ ); +}; + +export default CategoryFeedWidget; diff --git a/packages/ui/src/components/widgets/HomeWidget.tsx b/packages/ui/src/components/widgets/HomeWidget.tsx index a252b76c..2927fd4b 100644 --- a/packages/ui/src/components/widgets/HomeWidget.tsx +++ b/packages/ui/src/components/widgets/HomeWidget.tsx @@ -1,587 +1,614 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { useAuth } from '@/hooks/useAuth'; - -export interface HomeWidgetProps { - sortBy?: 'latest' | 'top'; - viewMode?: 'grid' | 'large' | 'list'; - showCategories?: boolean; - categorySlugs?: string; - categoryId?: string; - userId?: string; - showSortBar?: boolean; - showLayoutToggles?: boolean; - showFooter?: boolean; - center?: boolean; - columns?: number | 'auto'; - showTitle?: boolean; - showDescription?: boolean; - heading?: string; - headingLevel?: 'h1' | 'h2' | 'h3' | 'h4'; - variables?: Record; - searchQuery?: string; - initialContentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places'; - initialVisibilityFilter?: 'invisible' | 'private'; -} -import type { FeedSortOption } from '@/hooks/useFeedData'; -import { useMediaRefresh } from '@/contexts/MediaRefreshContext'; -import { useIsMobile } from '@/hooks/use-mobile'; -import MediaCard from '@/components/MediaCard'; -import { normalizeMediaType } from '@/lib/mediaRegistry'; - -import PhotoGrid from '@/components/PhotoGrid'; -import GalleryLarge from '@/components/GalleryLarge'; -import MobileFeed from '@/components/feed/MobileFeed'; -import { ListLayout } from '@/components/ListLayout'; -import CategoryTreeView from '@/components/CategoryTreeView'; -import Footer from '@/components/Footer'; -import { T } from '@/i18n'; -import { SEO } from '@/components/SEO'; - -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; -import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; -import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree, FileText, Image as ImageIcon, EyeOff, Lock, SlidersHorizontal, Layers, Camera, MapPin } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { serverUrl } from '@/lib/db'; - -const SIDEBAR_KEY = 'categorySidebarSize'; -const DEFAULT_SIDEBAR = 15; - -const HomeWidget: React.FC = ({ - sortBy: propSortBy = 'latest', - viewMode: propViewMode = 'grid', - showCategories: propShowCategories = false, - categorySlugs: propCategorySlugs = '', - categoryId: propCategoryId, - userId: propUserId = '', - showSortBar = true, - showLayoutToggles = true, - showFooter = true, - showTitle = true, - showDescription = false, - center = false, - columns = 'auto', - heading, - headingLevel = 'h2', - searchQuery, - initialContentType, - initialVisibilityFilter, -}) => { - const { refreshKey } = useMediaRefresh(); - const isMobile = useIsMobile(); - const { user: currentUser } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - // Derive content type from URL path (/user/:id/... or top-level /posts, /pages) - const urlContentType = useMemo(() => { - // Match /user/:id/posts|pages|pictures|files - const userMatch = location.pathname.match(/^\/user\/[^/]+\/(posts|pages|pictures|files)$/); - if (userMatch) return userMatch[1] as 'posts' | 'pages' | 'pictures' | 'files'; - // Match top-level /posts or /pages or /files - const topMatch = location.pathname.match(/^\/(posts|pages|files)$/); - if (topMatch) return topMatch[1] as 'posts' | 'pages' | 'files'; - return undefined; - }, [location.pathname]); - - // Effective initial: prop takes priority, then URL - const effectiveInitial = initialContentType ?? urlContentType; - - // Show visibility toggles on own profile OR in search mode when authenticated - const isOwnProfile = !!(propUserId && currentUser?.id === propUserId) || !!(searchQuery && currentUser); - - // Local state driven from props (with user overrides) - const [sortBy, setSortBy] = useState(propSortBy); - const [viewMode, setViewMode] = useState<'grid' | 'large' | 'list'>(() => { - return (localStorage.getItem('feedViewMode') as 'grid' | 'large' | 'list') || propViewMode; - }); - const [showCategories, setShowCategories] = useState(propShowCategories); - - // Sync from prop changes - useEffect(() => { setSortBy(propSortBy); }, [propSortBy]); - useEffect(() => { setShowCategories(propShowCategories); }, [propShowCategories]); - useEffect(() => { setViewMode(propViewMode); }, [propViewMode]); - - // Content type filter - const [contentType, setContentType] = useState<'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined>( - String(effectiveInitial) === 'all' ? undefined : (effectiveInitial as any) - ); - - // Sync when URL changes (e.g. browser back/forward) - useEffect(() => { - setContentType(String(effectiveInitial) === 'all' ? undefined : (effectiveInitial as any)); - }, [effectiveInitial]); - - // Navigate URL when content type changes (only when rendered on a user profile or in search) - const handleContentTypeChange = useCallback((newType: 'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined) => { - setContentType(newType); - if (propUserId) { - const basePath = `/user/${propUserId}`; - const targetPath = newType ? `${basePath}/${newType}` : basePath; - navigate(targetPath, { replace: true }); - } else if (searchQuery) { - const params = new URLSearchParams(location.search); - if (newType) params.set('type', newType); - else params.delete('type'); - navigate(`/search?${params.toString()}`, { replace: true }); - } - }, [propUserId, searchQuery, location.search, navigate]); - - // Visibility filter (own profile only) — exclusive: shows ONLY invisible or ONLY private - const [visibilityFilter, setVisibilityFilter] = useState<'invisible' | 'private' | undefined>(initialVisibilityFilter); - - useEffect(() => { - setVisibilityFilter(initialVisibilityFilter); - }, [initialVisibilityFilter]); - - const handleVisibilityFilterChange = useCallback((value: string) => { - const newValue = value === 'invisible' || value === 'private' ? value : undefined; - setVisibilityFilter(newValue); - if (searchQuery) { - const params = new URLSearchParams(location.search); - if (newValue) params.set('visibilityFilter', newValue); - else params.delete('visibilityFilter'); - navigate(`/search?${params.toString()}`, { replace: true }); - } - }, [searchQuery, location.search, navigate]); - - useEffect(() => { - localStorage.setItem('feedViewMode', viewMode); - }, [viewMode]); - - const categorySlugs = useMemo(() => { - if (!propCategorySlugs) return undefined; - return propCategorySlugs.split(',').map(s => s.trim()).filter(Boolean); - }, [propCategorySlugs]); - - // Derive source/sourceId for user filtering - const feedSource = searchQuery ? 'search' as const : propUserId ? 'user' as const : 'home' as const; - const feedSourceId = searchQuery || propUserId || undefined; - - // Mobile sheet state - const [sheetOpen, setSheetOpen] = useState(false); - const closeSheet = useCallback(() => setSheetOpen(false), []); - - // Persist sidebar size - const [sidebarSize, setSidebarSize] = useState(() => { - const stored = localStorage.getItem(SIDEBAR_KEY); - return stored ? Number(stored) : DEFAULT_SIDEBAR; - }); - const handleSidebarResize = useCallback((size: number) => { - setSidebarSize(size); - localStorage.setItem(SIDEBAR_KEY, String(size)); - }, []); - - // Navigation helpers — toggle local state - const handleSortChange = useCallback((value: string) => { - if (!value) return; - if (value === 'latest') setSortBy('latest'); - else if (value === 'top') setSortBy('top'); - else if (value === 'categories') setShowCategories(prev => !prev); - }, []); - - const handleCategoriesToggle = useCallback(() => { - setShowCategories(prev => { - const next = !prev; - if (next && isMobile) setSheetOpen(true); - return next; - }); - }, [isMobile]); - - // --- Shared sort + categories toggle bar --- - const renderSortBar = (size?: 'sm') => ( -
- - - - Latest - - - - Top - - - - - - Categories - - - {/* Content type + visibility — collapsed into a compact dropdown */} - - - - - - Content type - { - if (v === 'all') handleContentTypeChange(undefined); - else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places'); - }}> - - All - - - Posts - - - Pages - - {searchQuery && ( - - Places - - )} - {isOwnProfile && ( - - Pictures - - )} - - Files - - - {isOwnProfile && ( - <> - - Visibility - - - All - - - Invisible - - - Private - - - - )} - - -
- ); - - // --- Pictures grid (standalone pictures, not posts) --- - const [pictures, setPictures] = useState([]); - const [picturesLoading, setPicturesLoading] = useState(false); - - useEffect(() => { - if (contentType !== 'pictures' || !propUserId) return; - let cancelled = false; - setPicturesLoading(true); - import('@/modules/posts/client-pictures').then(({ fetchPictures }) => - fetchPictures({ userId: propUserId }) - ).then(result => { - if (!cancelled) setPictures(result.data); - }).catch(err => console.error('Failed to load pictures', err)) - .finally(() => { if (!cancelled) setPicturesLoading(false); }); - return () => { cancelled = true; }; - }, [contentType, propUserId, refreshKey]); - - const renderPicturesGrid = () => { - if (picturesLoading) { - return
Loading pictures...
; - } - if (pictures.length === 0) { - return
No pictures yet
; - } - const SERVER_URL = serverUrl || ''; - const isAuto = columns === 'auto'; - const gridColsClass = isAuto ? 'grid-cols-[repeat(auto-fit,minmax(250px,350px))] justify-center' : - Number(columns) === 1 ? 'grid-cols-1' : - Number(columns) === 2 ? 'grid-cols-1 md:grid-cols-2' : - Number(columns) === 3 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : - Number(columns) === 5 ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' : - Number(columns) === 6 ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6' : - 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 lg:grid-cols-5'; // default 4-ish/5 - - return ( -
- {pictures.map(pic => { - const thumbUrl = pic.thumbnail_url || pic.image_url; - const optimized = thumbUrl - ? thumbUrl.startsWith('http') - ? thumbUrl - : `${SERVER_URL}/api/v1/image?url=${encodeURIComponent(thumbUrl)}&w=400&q=75` - : undefined; - - return ( -
- navigate(`/post/${pic.id}`)} - onLike={() => { }} - onDelete={() => { }} - onEdit={() => { }} - created_at={pic.created_at} - job={pic.job} - responsive={pic.responsive} - apiUrl={SERVER_URL} - - /> -
- ); - })} -
- ); - }; - - // --- Feed views --- - const renderFeed = () => { - // When 'pictures' content type is selected, render the standalone grid - if (contentType === 'pictures') { - return renderPicturesGrid(); - } - - if (isMobile) { - return viewMode === 'list' ? ( - - ) : ( - window.location.href = `/post/${id}`} showTitle={showTitle} showDescription={showDescription} /> - ); - } - - if (viewMode === 'grid') { - return ; - } else if (viewMode === 'large') { - return ; - } - return ; - }; - - return ( -
- - - {/* Mobile: Sheet for category navigation */} - {isMobile && ( - - - - Categories - Browse categories - -
- -
-
-
- )} - -
- {isMobile ? ( - /* ---- Mobile layout ---- */ -
-
-
- {heading && (() => { - const H = headingLevel || 'h2'; - return {heading}; - })()} - {showSortBar && ( - - - - - - - - - )} - {showSortBar && ( - - - - - - )} -
- {categorySlugs && categorySlugs.length > 0 && ( - - {categorySlugs.map(s => s.replace(/-/g, ' ')).join(', ')} - - )} -
- {showLayoutToggles && ( - v && setViewMode(v as any)}> - - - - - - - - )} - {showSortBar && ( - - - - - - Content - { - if (v === 'all') handleContentTypeChange(undefined); - else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places'); - }}> - - All - - - Posts - - - Pages - - {searchQuery && ( - - Places - - )} - {isOwnProfile && ( - - Pictures - - )} - - Files - - - {isOwnProfile && ( - <> - - Visibility - - - All - - - Invisible - - - Private - - - - )} - - - )} -
-
- {renderFeed()} -
- ) : showCategories ? ( - /* ---- Desktop with category sidebar ---- */ -
-
-
- {heading && (() => { - const H = headingLevel || 'h2'; - const cls = H === 'h1' ? 'text-2xl' : H === 'h2' ? 'text-xl' : H === 'h3' ? 'text-lg' : 'text-base'; - return {heading}; - })()} - {showSortBar && renderSortBar()} -
- {showLayoutToggles && ( - v && setViewMode(v as any)}> - - - - - - - - - - - )} -
- - - -
-
- Categories -
- -
-
- - - {renderFeed()} - -
-
- ) : ( - /* ---- Desktop without sidebar ---- */ -
-
-
- {heading && (() => { - const H = headingLevel || 'h2'; - const cls = H === 'h1' ? 'text-2xl' : H === 'h2' ? 'text-xl' : H === 'h3' ? 'text-lg' : 'text-base'; - return {heading}; - })()} - {showSortBar && renderSortBar()} -
- {showLayoutToggles && ( - v && setViewMode(v as any)}> - - - - - - - - - - - )} -
- {renderFeed()} -
- )} -
- {showFooter &&
} -
- ); -}; - -export default HomeWidget; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import type { FeedSortOption } from '@/hooks/useFeedData'; +import { useMediaRefresh } from '@/contexts/MediaRefreshContext'; +import { useIsMobile } from '@/hooks/use-mobile'; +import MediaCard from '@/components/MediaCard'; +import { normalizeMediaType } from '@/lib/mediaRegistry'; + +import PhotoGrid from '@/components/PhotoGrid'; +import GalleryLarge from '@/components/GalleryLarge'; +import MobileFeed from '@/components/feed/MobileFeed'; +import { ListLayout } from '@/components/ListLayout'; +import CategoryTreeView from '@/components/CategoryTreeView'; +import Footer from '@/components/Footer'; +import { T } from '@/i18n'; +import { SEO } from '@/components/SEO'; + +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; +import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree, FileText, Image as ImageIcon, EyeOff, Lock, SlidersHorizontal, Layers, Camera, MapPin } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { serverUrl } from '@/lib/db'; + +/** Feed props for {@link HomeWidget} (also the base for category-feed and other composites). */ +export interface HomeWidgetProps { + sortBy?: 'latest' | 'top'; + viewMode?: 'grid' | 'large' | 'list'; + showCategories?: boolean; + /** Comma-separated category slugs (optional filter) */ + categorySlugs?: string; + /** Category ID for feed APIs when resolved from a picker */ + categoryId?: string; + userId?: string; + showSortBar?: boolean; + showLayoutToggles?: boolean; + showFooter?: boolean; + center?: boolean; + columns?: number | 'auto'; + showTitle?: boolean; + showDescription?: boolean; + /** Author avatar and name on cards (grid, large, list, and mobile feed). */ + showAuthor?: boolean; + /** Likes and comments UI on cards (matches PageCard/MediaCard “actions”). */ + showSocial?: boolean; + /** Section heading displayed above the feed */ + heading?: string; + /** Heading level: h1–h4 */ + headingLevel?: 'h1' | 'h2' | 'h3' | 'h4'; + variables?: Record; + searchQuery?: string; + initialContentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places'; + initialVisibilityFilter?: 'invisible' | 'private'; + /** Widget editor: composite widgets use this; HomeWidget ignores it */ + isEditMode?: boolean; + onPropsChange?: (props: Record) => void; +} + +const SIDEBAR_KEY = 'categorySidebarSize'; +const DEFAULT_SIDEBAR = 15; + +const HomeWidget: React.FC = ({ + sortBy: propSortBy = 'latest', + viewMode: propViewMode = 'grid', + showCategories: propShowCategories = false, + categorySlugs: propCategorySlugs = '', + categoryId: propCategoryId, + userId: propUserId = '', + showSortBar = true, + showLayoutToggles = true, + showFooter = true, + showTitle = true, + showDescription = false, + showAuthor = true, + showSocial = true, + center = false, + columns = 'auto', + heading, + headingLevel = 'h2', + variables: _variables, + searchQuery, + initialContentType, + initialVisibilityFilter, + isEditMode: _isEditMode, + onPropsChange: _onPropsChange, +}) => { + const { refreshKey } = useMediaRefresh(); + const isMobile = useIsMobile(); + const { user: currentUser } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + // Derive content type from URL path (/user/:id/... or top-level /posts, /pages) + const urlContentType = useMemo(() => { + // Match /user/:id/posts|pages|pictures|files + const userMatch = location.pathname.match(/^\/user\/[^/]+\/(posts|pages|pictures|files)$/); + if (userMatch) return userMatch[1] as 'posts' | 'pages' | 'pictures' | 'files'; + // Match top-level /posts or /pages or /files + const topMatch = location.pathname.match(/^\/(posts|pages|files)$/); + if (topMatch) return topMatch[1] as 'posts' | 'pages' | 'files'; + return undefined; + }, [location.pathname]); + + // Effective initial: prop takes priority, then URL + const effectiveInitial = initialContentType ?? urlContentType; + + // Show visibility toggles on own profile OR in search mode when authenticated + const isOwnProfile = !!(propUserId && currentUser?.id === propUserId) || !!(searchQuery && currentUser); + + // Local state driven from props (with user overrides) + const [sortBy, setSortBy] = useState(propSortBy); + const [viewMode, setViewMode] = useState<'grid' | 'large' | 'list'>(() => { + return (localStorage.getItem('feedViewMode') as 'grid' | 'large' | 'list') || propViewMode; + }); + const [showCategories, setShowCategories] = useState(propShowCategories); + + // Sync from prop changes + useEffect(() => { setSortBy(propSortBy); }, [propSortBy]); + useEffect(() => { setShowCategories(propShowCategories); }, [propShowCategories]); + useEffect(() => { setViewMode(propViewMode); }, [propViewMode]); + + // Content type filter + const [contentType, setContentType] = useState<'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined>( + String(effectiveInitial) === 'all' ? undefined : (effectiveInitial as any) + ); + + // Sync when URL changes (e.g. browser back/forward) + useEffect(() => { + setContentType(String(effectiveInitial) === 'all' ? undefined : (effectiveInitial as any)); + }, [effectiveInitial]); + + // Navigate URL when content type changes (only when rendered on a user profile or in search) + const handleContentTypeChange = useCallback((newType: 'posts' | 'pages' | 'pictures' | 'files' | 'places' | undefined) => { + setContentType(newType); + if (propUserId) { + const basePath = `/user/${propUserId}`; + const targetPath = newType ? `${basePath}/${newType}` : basePath; + navigate(targetPath, { replace: true }); + } else if (searchQuery) { + const params = new URLSearchParams(location.search); + if (newType) params.set('type', newType); + else params.delete('type'); + navigate(`/search?${params.toString()}`, { replace: true }); + } + }, [propUserId, searchQuery, location.search, navigate]); + + // Visibility filter (own profile only) — exclusive: shows ONLY invisible or ONLY private + const [visibilityFilter, setVisibilityFilter] = useState<'invisible' | 'private' | undefined>(initialVisibilityFilter); + + useEffect(() => { + setVisibilityFilter(initialVisibilityFilter); + }, [initialVisibilityFilter]); + + const handleVisibilityFilterChange = useCallback((value: string) => { + const newValue = value === 'invisible' || value === 'private' ? value : undefined; + setVisibilityFilter(newValue); + if (searchQuery) { + const params = new URLSearchParams(location.search); + if (newValue) params.set('visibilityFilter', newValue); + else params.delete('visibilityFilter'); + navigate(`/search?${params.toString()}`, { replace: true }); + } + }, [searchQuery, location.search, navigate]); + + useEffect(() => { + localStorage.setItem('feedViewMode', viewMode); + }, [viewMode]); + + const categorySlugs = useMemo(() => { + if (!propCategorySlugs) return undefined; + return propCategorySlugs.split(',').map(s => s.trim()).filter(Boolean); + }, [propCategorySlugs]); + + const cardPreset = useMemo( + () => ({ + showTitle, + showDescription, + showAuthor, + showActions: showSocial, + }), + [showTitle, showDescription, showAuthor, showSocial] + ); + + // Derive source/sourceId for user filtering + const feedSource = searchQuery ? 'search' as const : propUserId ? 'user' as const : 'home' as const; + const feedSourceId = searchQuery || propUserId || undefined; + + // Mobile sheet state + const [sheetOpen, setSheetOpen] = useState(false); + const closeSheet = useCallback(() => setSheetOpen(false), []); + + // Persist sidebar size + const [sidebarSize, setSidebarSize] = useState(() => { + const stored = localStorage.getItem(SIDEBAR_KEY); + return stored ? Number(stored) : DEFAULT_SIDEBAR; + }); + const handleSidebarResize = useCallback((size: number) => { + setSidebarSize(size); + localStorage.setItem(SIDEBAR_KEY, String(size)); + }, []); + + // Navigation helpers — toggle local state + const handleSortChange = useCallback((value: string) => { + if (!value) return; + if (value === 'latest') setSortBy('latest'); + else if (value === 'top') setSortBy('top'); + else if (value === 'categories') setShowCategories(prev => !prev); + }, []); + + const handleCategoriesToggle = useCallback(() => { + setShowCategories(prev => { + const next = !prev; + if (next && isMobile) setSheetOpen(true); + return next; + }); + }, [isMobile]); + + // --- Shared sort + categories toggle bar --- + const renderSortBar = (size?: 'sm') => ( +
+ + + + Latest + + + + Top + + + + + + Categories + + + {/* Content type + visibility — collapsed into a compact dropdown */} + + + + + + Content type + { + if (v === 'all') handleContentTypeChange(undefined); + else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places'); + }}> + + All + + + Posts + + + Pages + + {searchQuery && ( + + Places + + )} + {isOwnProfile && ( + + Pictures + + )} + + Files + + + {isOwnProfile && ( + <> + + Visibility + + + All + + + Invisible + + + Private + + + + )} + + +
+ ); + + // --- Pictures grid (standalone pictures, not posts) --- + const [pictures, setPictures] = useState([]); + const [picturesLoading, setPicturesLoading] = useState(false); + + useEffect(() => { + if (contentType !== 'pictures' || !propUserId) return; + let cancelled = false; + setPicturesLoading(true); + import('@/modules/posts/client-pictures').then(({ fetchPictures }) => + fetchPictures({ userId: propUserId }) + ).then(result => { + if (!cancelled) setPictures(result.data); + }).catch(err => console.error('Failed to load pictures', err)) + .finally(() => { if (!cancelled) setPicturesLoading(false); }); + return () => { cancelled = true; }; + }, [contentType, propUserId, refreshKey]); + + const renderPicturesGrid = () => { + if (picturesLoading) { + return
Loading pictures...
; + } + if (pictures.length === 0) { + return
No pictures yet
; + } + const SERVER_URL = serverUrl || ''; + const isAuto = columns === 'auto'; + const gridColsClass = isAuto ? 'grid-cols-[repeat(auto-fit,minmax(250px,350px))] justify-center' : + Number(columns) === 1 ? 'grid-cols-1' : + Number(columns) === 2 ? 'grid-cols-1 md:grid-cols-2' : + Number(columns) === 3 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : + Number(columns) === 5 ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' : + Number(columns) === 6 ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6' : + 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 lg:grid-cols-5'; // default 4-ish/5 + + return ( +
+ {pictures.map(pic => { + const thumbUrl = pic.thumbnail_url || pic.image_url; + const optimized = thumbUrl + ? thumbUrl.startsWith('http') + ? thumbUrl + : `${SERVER_URL}/api/v1/image?url=${encodeURIComponent(thumbUrl)}&w=400&q=75` + : undefined; + + return ( +
+ navigate(`/post/${pic.id}`)} + onLike={() => { }} + onDelete={() => { }} + onEdit={() => { }} + created_at={pic.created_at} + job={pic.job} + responsive={pic.responsive} + apiUrl={SERVER_URL} + preset={cardPreset} + /> +
+ ); + })} +
+ ); + }; + + // --- Feed views --- + const renderFeed = () => { + // When 'pictures' content type is selected, render the standalone grid + if (contentType === 'pictures') { + return renderPicturesGrid(); + } + + if (isMobile) { + return viewMode === 'list' ? ( + + ) : ( + window.location.href = `/post/${id}`} showTitle={showTitle} showDescription={showDescription} showAuthor={showAuthor} showSocial={showSocial} /> + ); + } + + if (viewMode === 'grid') { + return ; + } else if (viewMode === 'large') { + return ; + } + return ; + }; + + return ( +
+ + + {/* Mobile: Sheet for category navigation */} + {isMobile && ( + + + + Categories + Browse categories + +
+ +
+
+
+ )} + +
+ {isMobile ? ( + /* ---- Mobile layout ---- */ +
+
+
+ {heading && (() => { + const H = headingLevel || 'h2'; + return {heading}; + })()} + {showSortBar && ( + + + + + + + + + )} + {showSortBar && ( + + + + + + )} +
+ {categorySlugs && categorySlugs.length > 0 && ( + + {categorySlugs.map(s => s.replace(/-/g, ' ')).join(', ')} + + )} +
+ {showLayoutToggles && ( + v && setViewMode(v as any)}> + + + + + + + + )} + {showSortBar && ( + + + + + + Content + { + if (v === 'all') handleContentTypeChange(undefined); + else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places'); + }}> + + All + + + Posts + + + Pages + + {searchQuery && ( + + Places + + )} + {isOwnProfile && ( + + Pictures + + )} + + Files + + + {isOwnProfile && ( + <> + + Visibility + + + All + + + Invisible + + + Private + + + + )} + + + )} +
+
+ {renderFeed()} +
+ ) : showCategories ? ( + /* ---- Desktop with category sidebar ---- */ +
+
+
+ {heading && (() => { + const H = headingLevel || 'h2'; + const cls = H === 'h1' ? 'text-2xl' : H === 'h2' ? 'text-xl' : H === 'h3' ? 'text-lg' : 'text-base'; + return {heading}; + })()} + {showSortBar && renderSortBar()} +
+ {showLayoutToggles && ( + v && setViewMode(v as any)}> + + + + + + + + + + + )} +
+ + + +
+
+ Categories +
+ +
+
+ + + {renderFeed()} + +
+
+ ) : ( + /* ---- Desktop without sidebar ---- */ +
+
+
+ {heading && (() => { + const H = headingLevel || 'h2'; + const cls = H === 'h1' ? 'text-2xl' : H === 'h2' ? 'text-xl' : H === 'h3' ? 'text-lg' : 'text-base'; + return {heading}; + })()} + {showSortBar && renderSortBar()} +
+ {showLayoutToggles && ( + v && setViewMode(v as any)}> + + + + + + + + + + + )} +
+ {renderFeed()} +
+ )} +
+ {showFooter &&
} +
+ ); +}; + +export default HomeWidget; diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index 354e1082..b254a711 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -1,1410 +1,1438 @@ -import { lazy } from 'react'; -import { widgetRegistry } from './widgetRegistry'; -import { translate } from '@/i18n'; -import { - Monitor, - Layout, - FileText, - Code, - Video, - MessageSquare, - Map as MapIcon, -} from 'lucide-react'; - -import type { - HtmlWidgetProps, - PhotoGridProps, - PhotoCardWidgetProps, - PhotoGridWidgetProps, - TabsWidgetProps, - GalleryWidgetProps, - PageCardWidgetProps, - MarkdownTextWidgetProps, - LayoutContainerWidgetProps, - FileBrowserWidgetProps, -} from '@polymech/shared'; - -import PageCardWidget from '@/modules/pages/PageCardWidget'; -import PhotoGrid from '@/components/PhotoGrid'; -import PhotoGridWidget from '@/components/widgets/PhotoGridWidget'; -import PhotoCardWidget from '@/components/widgets/PhotoCardWidget'; -import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget'; -import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget'; -import GalleryWidget from '@/components/widgets/GalleryWidget'; -import TabsWidget from '@/components/widgets/TabsWidget'; -import { HtmlWidget } from '@/components/widgets/HtmlWidget'; -import HomeWidget from '@/components/widgets/HomeWidget'; -import VideoBannerWidget from '@/components/widgets/VideoBannerWidget'; -import CategoryFeedWidget from '@/components/widgets/CategoryFeedWidget'; -import MenuWidget from '@/components/widgets/MenuWidget'; - -const SupportChatWidget = lazy(() => import('@/components/widgets/SupportChatWidget')); -const CompetitorsMapWidget = lazy(() => import('@/components/widgets/CompetitorsMapWidget')); -const FileBrowserWidget = lazy(() => import('@/modules/storage/FileBrowserWidget').then(m => ({ default: m.FileBrowserWidget }))); - -export function registerAllWidgets() { - // Clear existing registrations (useful for HMR) - widgetRegistry.clear(); - - // HTML Widget - widgetRegistry.register({ - component: HtmlWidget, - metadata: { - id: 'html-widget', - name: translate('HTML Content'), - category: 'display', - description: translate('Render HTML content with variable substitution'), - icon: Code, - defaultProps: { - content: '
\n

Hello ${name}

\n

Welcome to our custom widget!

\n
', - variables: '{\n "name": "World"\n}', - className: '' - }, - configSchema: { - content: { - type: 'markdown', // Using markdown editor for larger text area - label: 'HTML Content', - description: 'Enter your HTML code here. Use ${varName} for substitutions.', - default: '
Hello World
' - }, - variables: { - type: 'markdown', // Using markdown/textarea for JSON input for now - label: 'Variables (JSON)', - description: 'JSON object defining variables for substitution.', - default: '{}' - }, - className: { - type: 'classname', - label: 'CSS Class', - description: 'Tailwind classes for the container', - default: '' - } - }, - minSize: { width: 300, height: 100 }, - resizable: true, - tags: ['html', 'code', 'custom', 'embed'] - } - }); - - // Photo widgets - widgetRegistry.register({ - component: PhotoGrid, - metadata: { - id: 'photo-grid', - name: translate('Photo Grid'), - category: 'custom', - description: translate('Display photos in a responsive grid layout'), - icon: Monitor, - defaultProps: { - variables: {} - }, - // Note: PhotoGrid fetches data internally based on navigation context - // For configurable picture selection, use 'photo-grid-widget' instead - minSize: { width: 300, height: 200 }, - resizable: true, - tags: ['photo', 'grid', 'gallery'] - } - }); - - widgetRegistry.register({ - component: PhotoCardWidget, - metadata: { - id: 'photo-card', - name: translate('Photo Card'), - category: 'custom', - description: translate('Display a single photo card with details'), - icon: Monitor, - defaultProps: { - pictureId: null, - postId: null, - showHeader: true, - showFooter: true, - showAuthor: true, - showActions: true, - showTitle: true, - showDescription: true, - contentDisplay: 'below', - imageFit: 'contain', - variables: {} - }, - configSchema: { - pictureId: { - type: 'imagePicker', - label: 'Select Picture', - description: 'Choose a picture from your published images', - default: null - }, - showHeader: { - type: 'boolean', - label: 'Show Header', - description: 'Show header with author information', - default: true - }, - showFooter: { - type: 'boolean', - label: 'Show Footer', - description: 'Show footer with likes, comments, and actions', - default: true - }, - showAuthor: { - type: 'boolean', - label: 'Include Author', - description: 'Show author avatar and name', - default: true - }, - showActions: { - type: 'boolean', - label: 'Include Actions', - description: 'Show like, comment, download buttons', - default: true - }, - showTitle: { - type: 'boolean', - label: 'Show Title', - description: 'Display the picture title', - default: true - }, - showDescription: { - type: 'boolean', - label: 'Show Description', - description: 'Display the picture description', - default: true - }, - contentDisplay: { - type: 'select', - label: 'Content Display', - description: 'How to display title and description', - options: [ - { value: 'below', label: 'Below Image' }, - { value: 'overlay', label: 'Overlay on Hover' }, - { value: 'overlay-always', label: 'Overlay (Always)' } - ], - default: 'below' - }, - imageFit: { - type: 'select', - label: 'Image Fit', - description: 'How the image should fit within the card', - options: [ - { value: 'contain', label: 'Contain' }, - { value: 'cover', label: 'Cover' } - ], - default: 'contain' - } - }, - minSize: { width: 300, height: 400 }, - resizable: true, - tags: ['photo', 'card', 'display'] - } - }); - - widgetRegistry.register({ - component: PhotoGridWidget, - metadata: { - id: 'photo-grid-widget', - name: translate('Photo Grid Widget'), - category: 'custom', - description: translate('Display a customizable grid of selected photos'), - icon: Monitor, - defaultProps: { - pictureIds: [], - columns: 'auto', - variables: {} - }, - configSchema: { - pictureIds: { - type: 'imagePicker', // We'll need to upgrade the config renderer to handle array/multi-select if not already supported, or rely on internal EditMode - label: 'Select Pictures', - description: 'Choose pictures to display in the grid', - default: [] - }, - columns: { - type: 'select', - label: 'Grid Columns', - description: 'Number of columns to display in grid view', - options: [ - { value: 'auto', label: 'Auto (Responsive)' }, - { value: '1', label: '1 Column' }, - { value: '2', label: '2 Columns' }, - { value: '3', label: '3 Columns' }, - { value: '4', label: '4 Columns' }, - { value: '5', label: '5 Columns' }, - { value: '6', label: '6 Columns' } - ], - default: 'auto' - } - }, - minSize: { width: 300, height: 300 }, - resizable: true, - tags: ['photo', 'grid', 'gallery', 'custom'] - } - }); - - widgetRegistry.register({ - component: TabsWidget, - metadata: { - id: 'tabs-widget', - name: translate('Tabs Widget'), - category: 'layout', - description: translate('Organize content into switchable tabs'), - icon: Layout, - defaultProps: { - tabs: [ - { - id: 'tab-1', - label: 'Tab 1', - layoutId: 'tab-layout-1', - layoutData: { - id: 'tab-layout-1', - name: 'Tab 1', - containers: [], - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0' - } - }, - { - id: 'tab-2', - label: 'Tab 2', - layoutId: 'tab-layout-2', - layoutData: { - id: 'tab-layout-2', - name: 'Tab 2', - containers: [], - createdAt: Date.now(), - updatedAt: Date.now(), - version: '1.0.0' - } - } - ], - orientation: 'horizontal', - tabBarPosition: 'top', - tabBarClassName: '', - contentClassName: '', - variables: {} - } as unknown as TabsWidgetProps, - - configSchema: { - tabs: { - type: 'tabs-editor', - label: 'Tabs', - description: 'Manage tabs and their order', - default: [] - }, - tabBarPosition: { - type: 'select', - label: 'Tab Bar Position', - description: 'Position of the tab bar', - options: [ - { value: 'top', label: 'Top' }, - { value: 'bottom', label: 'Bottom' }, - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' } - ], - default: 'top' - }, - tabBarClassName: { - type: 'classname', - label: 'Tab Bar Style', - description: 'Tailwind classes for the tab bar', - default: '' - }, - contentClassName: { - type: 'classname', - label: 'Content Area Style', - description: 'Tailwind classes for the content area', - default: '' - } - }, - minSize: { width: 400, height: 300 }, - resizable: true, - tags: ['layout', 'tabs', 'container'] - }, - getNestedLayouts: (props: TabsWidgetProps) => { - if (!props.tabs || !Array.isArray(props.tabs)) return []; - return props.tabs.map((tab: unknown) => ({ - id: (tab as any).id, - label: (tab as any).label, - layoutId: (tab as any).layoutId - })); - } - }); - - widgetRegistry.register({ - component: GalleryWidget, - metadata: { - id: 'gallery-widget', - name: translate('Gallery'), - category: 'custom', - description: translate('Interactive gallery with main viewer and filmstrip navigation'), - icon: Monitor, - defaultProps: { - pictureIds: [], - thumbnailLayout: 'strip', - imageFit: 'contain', - thumbnailsPosition: 'bottom', - thumbnailsOrientation: 'horizontal', - zoomEnabled: false, - showVersions: false, - showTitle: false, - showDescription: false, - autoPlayVideos: false, - thumbnailsClassName: '', - variables: {} - }, - configSchema: { - pictureIds: { - type: 'imagePicker', - label: 'Select Pictures', - description: 'Choose pictures to display in the gallery', - default: [], - multiSelect: true - }, - showTitle: { - type: 'boolean', - label: 'Show Title', - description: 'Display the title of each picture below the main image', - default: false - }, - showDescription: { - type: 'boolean', - label: 'Show Description', - description: 'Display the description of each picture below the main image', - default: false - }, - thumbnailLayout: { - type: 'select', - label: 'Thumbnail Layout', - description: 'How to display thumbnails below the main image', - options: [ - { value: 'strip', label: 'Horizontal Strip (scrollable)' }, - { value: 'grid', label: 'Grid (multiple rows, fit width)' } - ], - default: 'strip' - }, - imageFit: { - type: 'select', - label: 'Main Image Fit', - description: 'How the main image should fit within its container', - options: [ - { value: 'contain', label: 'Contain (fit within bounds, show full image)' }, - { value: 'cover', label: 'Cover (fill container, may crop image)' } - ], - default: 'cover' - }, - thumbnailsPosition: { - type: 'select', - label: 'Thumbnails Position', - description: 'Where to place the thumbnails relative to the main image', - options: [ - { value: 'bottom', label: 'Bottom' }, - { value: 'top', label: 'Top' }, - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' } - ], - default: 'bottom' - }, - thumbnailsOrientation: { - type: 'select', - label: 'Thumbnails Orientation', - description: 'Direction of the thumbnail strip', - options: [ - { value: 'horizontal', label: 'Horizontal' }, - { value: 'vertical', label: 'Vertical' } - ], - default: 'horizontal' - }, - zoomEnabled: { - type: 'boolean', - label: 'Enable Pan Zoom', - description: 'Enlarge image and pan on hover', - default: false - }, - showVersions: { - type: 'boolean', - label: 'Show Versions', - description: 'Show version navigation in the filmstrip for pictures with versions', - default: false - }, - autoPlayVideos: { - type: 'boolean', - label: 'Auto Play Videos', - description: 'Automatically play video items when selected (muted)', - default: false - }, - thumbnailsClassName: { - type: 'classname', - label: 'Thumbnails CSS Class', - description: 'Additional CSS classes for the thumbnail strip container', - default: '' - } - }, - minSize: { width: 600, height: 500 }, - resizable: true, - tags: ['photo', 'gallery', 'viewer', 'slideshow'] - } - }); - - widgetRegistry.register({ - component: PageCardWidget, - metadata: { - id: 'page-card', - name: translate('Page Card'), - category: 'custom', - description: translate('Display a single page card with details'), - icon: FileText, - defaultProps: { - pageId: null, - showHeader: true, - showFooter: true, - showAuthor: true, - showActions: true, - contentDisplay: 'below', - variables: {} - }, - configSchema: { - pageId: { - type: 'pagePicker', - label: 'Select Page', - description: 'Choose a page to display', - default: null - }, - showHeader: { - type: 'boolean', - label: 'Show Header', - description: 'Show header with author information', - default: true - }, - showFooter: { - type: 'boolean', - label: 'Show Footer', - description: 'Show footer with likes, comments, and actions', - default: true - }, - showAuthor: { - type: 'boolean', - label: 'Show Author', - description: 'Show author avatar and name', - default: true - }, - showActions: { - type: 'boolean', - label: 'Show Actions', - description: 'Show like and comment buttons', - default: true - }, - contentDisplay: { - type: 'select', - label: 'Content Display', - description: 'How to display title and description', - options: [ - { value: 'below', label: 'Below Image' }, - { value: 'overlay', label: 'Overlay on Hover' }, - { value: 'overlay-always', label: 'Overlay (Always)' } - ], - default: 'below' - } - }, - minSize: { width: 300, height: 400 }, - resizable: true, - tags: ['page', 'card', 'display'] - } - }); - - // Content widgets - widgetRegistry.register({ - component: MarkdownTextWidget, - metadata: { - id: 'markdown-text', - name: translate('Text Block'), - category: 'display', - description: translate('Add rich text content with Markdown support'), - icon: FileText, - defaultProps: { - content: '', - placeholder: 'Enter your text here...', - variables: {} - }, - configSchema: { - placeholder: { - type: 'text', - label: 'Placeholder Text', - description: 'Text shown when content is empty', - default: 'Enter your text here...' - } - }, - minSize: { width: 300, height: 150 }, - resizable: true, - tags: ['text', 'markdown', 'content', 'editor'] - } - }); - - widgetRegistry.register({ - component: LayoutContainerWidget, - metadata: { - id: 'layout-container-widget', - name: translate('Nested Layout Container'), - category: 'custom', - description: translate('A widget that contains its own independent layout canvas.'), - icon: Layout, - defaultProps: { - nestedPageName: 'Nested Container', - showControls: true, - variables: {} - }, - configSchema: { - nestedPageName: { - type: 'text', - label: 'Canvas Name', - description: 'The display name for the nested layout canvas.', - default: 'Nested Container' - }, - showControls: { - type: 'boolean', - label: 'Show Canvas Controls', - description: 'Show the main controls (Add Container, Save, etc.) inside this nested canvas.', - default: true - } - }, - minSize: { width: 300, height: 200 }, - resizable: true, - tags: ['layout', 'container', 'nested', 'canvas'] - }, - getNestedLayouts: (props: LayoutContainerWidgetProps) => { - if (props.nestedPageId) { - return [{ - id: 'nested-container', - label: props.nestedPageName || 'Nested Container', - layoutId: props.nestedPageId - }]; - } - return []; - } - }); - - widgetRegistry.register({ - component: SupportChatWidget, - metadata: { - id: 'support-chat-widget', - name: translate('Support Chat Widget'), - category: 'custom', - description: translate('Floating support chat button that expands into a full chat panel.'), - icon: MessageSquare, - defaultProps: { - buttons: [{ id: 'default', label: 'Ask Questions', contextPrompt: '' }], - position: 'bottom-right', - mode: 'floating', - autoOpen: false, - variables: {} - }, - configSchema: { - position: { - type: 'select', - label: 'Button Position (Floating)', - description: 'Screen corner position (Only applies if Mode is Floating).', - options: [ - { value: 'bottom-right', label: 'Bottom Right' }, - { value: 'bottom-left', label: 'Bottom Left' } - ], - default: 'bottom-right' - }, - mode: { - type: 'select', - label: 'Display Mode', - description: 'Whether the chat button floats over the page or renders inline within the layout.', - options: [ - { value: 'floating', label: 'Floating (Corner)' }, - { value: 'inline', label: 'Inline (In Layout)' } - ], - default: 'floating' - }, - autoOpen: { - type: 'boolean', - label: 'Auto Open', - description: 'Open automatically when the page loads.', - default: false - } - }, - minSize: { width: 100, height: 100 }, - resizable: false, - tags: ['chat', 'support', 'ai', 'floating', 'button'] - } - }); - - // File Browser Widget - widgetRegistry.register({ - component: FileBrowserWidget, - metadata: { - id: 'file-browser', - name: translate('File Browser'), - category: 'custom', - description: translate('Browse files and directories on VFS mounts'), - icon: Monitor, - defaultProps: { - mount: 'root', - path: '/', - glob: '*.*', - mode: 'simple', - viewMode: 'list', - sortBy: 'name', - showToolbar: true, - canChangeMount: true, - allowFileViewer: true, - allowLightbox: true, - allowDownload: true, - allowPreview: false, - initialFile: '', - jail: false, - minHeight: '600px', - showStatusBar: true, - searchQuery: '', - variables: {} - }, - configSchema: { - mountAndPath: { - type: 'vfsPicker', - label: 'Mount & Initial Path', - description: 'Browse to select the mount and starting directory', - mountKey: 'mount', - pathKey: 'path', - defaultMount: 'home', - }, - searchQuery: { - type: 'text', - label: 'Initial Search Query', - description: 'If set, the file browser will start in search mode matching this query', - default: '' - }, - initialFile: { - type: 'text', - label: 'Initial File', - description: 'If set, automatically selects and opens this file on load (needs filename + extension)', - default: '' - }, - glob: { - type: 'selectWithText', - label: 'File Filter', - description: 'Filter which files are shown', - options: [ - { value: '*.*', label: 'All Files' }, - { value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif,mp4,webm,mov,avi,mkv}', label: 'Media (Images & Video)' }, - { value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif}', label: 'Images Only' }, - { value: '*.{mp4,webm,mov,avi,mkv,flv}', label: 'Videos Only' }, - ], - default: '*.*' - }, - mode: { - type: 'select', - label: 'Mode', - description: 'Simple = browse only. Advanced = file detail panel.', - options: [ - { value: 'simple', label: 'Simple' }, - { value: 'advanced', label: 'Advanced' } - ], - default: 'simple' - }, - viewMode: { - type: 'select', - label: 'View Mode', - description: 'List, thumbnail grid or tree view', - options: [ - { value: 'list', label: 'List' }, - { value: 'thumbs', label: 'Thumbnails' }, - { value: 'tree', label: 'Tree' } - ], - default: 'list' - }, - sortBy: { - type: 'select', - label: 'Sort By', - description: 'Default sort order', - options: [ - { value: 'name', label: 'Name' }, - { value: 'ext', label: 'Extension' }, - { value: 'date', label: 'Date' }, - { value: 'type', label: 'Type' } - ], - default: 'name' - }, - showToolbar: { - type: 'boolean', - label: 'Show Toolbar', - description: 'Show navigation toolbar with breadcrumbs, sort, and view toggle', - default: true - }, - canChangeMount: { - type: 'boolean', - label: 'Allow Mount Switching', - description: 'Let users switch between VFS mounts', - default: true - }, - allowLightbox: { - type: 'boolean', - label: 'Allow Image/Video Lightbox', - description: 'Open images and videos in a fullscreen lightbox', - default: true - }, - allowFileViewer: { - type: 'boolean', - label: 'Allow Text File Viewer', - description: 'Open text/code files in the built-in viewer', - default: true - }, - allowDownload: { - type: 'boolean', - label: 'Allow Download', - description: 'Show download button for files', - default: true - }, - allowPreview: { - type: 'boolean', - label: 'Allow Document & 3D Preview', - description: 'Open document and 3D files in a modal preview', - default: true - }, - jail: { - type: 'boolean', - label: 'Jail Mode', - description: 'Prevent navigating above the configured mount and path', - default: false - }, - minHeight: { - type: 'selectWithText', - label: 'Min Height', - description: 'Minimum height of the widget (e.g. 600px, 70vh, 50%)', - options: [ - { value: '400px', label: '400px' }, - { value: '500px', label: '500px' }, - { value: '600px', label: '600px' }, - { value: '800px', label: '800px' }, - { value: '50vh', label: '50vh' }, - { value: '70vh', label: '70vh' }, - ], - default: '600px' - }, - showStatusBar: { - type: 'boolean', - label: 'Show Status Bar', - description: 'Show item count and path info at the bottom', - default: true - } - }, - minSize: { width: 300, height: 300 }, - resizable: true, - tags: ['file', 'browser', 'vfs', 'storage', 'explorer'] - } - }); - - // Home Widget - widgetRegistry.register({ - component: HomeWidget, - metadata: { - id: 'home', - name: translate('Home Feed'), - category: 'display', - description: translate('Display the main home feed with photos, categories, and sorting'), - icon: Monitor, - defaultProps: { - sortBy: 'latest', - viewMode: 'grid', - showCategories: false, - categorySlugs: '', - userId: '', - showSortBar: true, - showLayoutToggles: true, - showFooter: true, - center: false, - searchQuery: '', - initialContentType: '', - variables: {} - }, - configSchema: { - sortBy: { - type: 'select', - label: 'Sort By', - description: 'Default sort order for the feed', - options: [ - { value: 'latest', label: 'Latest' }, - { value: 'top', label: 'Top' } - ], - default: 'latest' - }, - viewMode: { - type: 'select', - label: 'View Mode', - description: 'Default display mode for the feed', - options: [ - { value: 'grid', label: 'Grid' }, - { value: 'large', label: 'Large Gallery' }, - { value: 'list', label: 'List' } - ], - default: 'grid' - }, - showCategories: { - type: 'boolean', - label: 'Show Categories Sidebar', - description: 'Show the category tree sidebar on desktop', - default: false - }, - categorySlugs: { - type: 'text', - label: 'Category Filter', - description: 'Comma-separated category slugs to filter by (leave empty for all)', - default: '' - }, - userId: { - type: 'userPicker', - label: 'User Filter', - description: 'Filter feed to show only content from a specific user', - default: '' - }, - showSortBar: { - type: 'boolean', - label: 'Show Sort Bar', - description: 'Show the sort and category toggle bar', - default: true - }, - showLayoutToggles: { - type: 'boolean', - label: 'Show Layout Toggles', - description: 'Show grid/large/list view toggle buttons', - default: true - }, - showFooter: { - type: 'boolean', - label: 'Show Footer', - description: 'Show the site footer below the feed', - default: true - }, - center: { - type: 'boolean', - label: 'Center Content', - description: 'Center the widget content with a maximum width container', - default: false - }, - searchQuery: { - type: 'text', - label: 'Initial Search Query', - description: 'Load feed matching this search query', - default: '' - }, - initialContentType: { - type: 'select', - label: 'Content Type', - description: 'Filter by content type', - options: [ - { value: 'all', label: 'All Content' }, - { value: 'posts', label: 'Posts' }, - { value: 'pages', label: 'Pages' }, - { value: 'pictures', label: 'Pictures' }, - { value: 'files', label: 'Files' } - ], - default: 'all' - } - }, - minSize: { width: 400, height: 400 }, - resizable: true, - tags: ['home', 'feed', 'gallery', 'photos', 'categories'] - } - }); - - // Video Banner Widget - widgetRegistry.register({ - component: VideoBannerWidget, - metadata: { - id: 'video-banner', - name: translate('Hero'), - category: 'display', - description: translate('Full-width hero banner with background video, text overlay, and call-to-action buttons'), - icon: Video, - defaultProps: { - videoId: null, - posterImageId: null, - backgroundImageId: null, - heading: '', - description: '', - minHeight: 500, - overlayOpacity: 'medium', - objectFit: 'cover', - buttons: [], - variables: {} - }, - configSchema: { - videoId: { - type: 'imagePicker', - label: 'Background Video', - description: 'Select a video-intern picture to use as background', - default: null - }, - backgroundImageId: { - type: 'imagePicker', - label: 'Background Image', - description: 'Static image to use as background (when no video is set)', - default: null - }, - posterImageId: { - type: 'imagePicker', - label: 'Poster Image', - description: 'Fallback image shown while video loads', - default: null - }, - objectFit: { - type: 'select', - label: 'Image Fit', - description: 'How the background image fills the banner', - options: [ - { value: 'cover', label: 'Cover (fill, may crop)' }, - { value: 'contain', label: 'Contain (fit, may letterbox)' } - ], - default: 'cover' - }, - heading: { - type: 'text', - label: 'Heading', - description: 'Main banner heading text', - default: '' - }, - description: { - type: 'text', - label: 'Description', - description: 'Banner description text', - default: '' - }, - minHeight: { - type: 'number', - label: 'Minimum Height (px)', - description: 'Minimum height of the banner in pixels', - default: 500 - }, - overlayOpacity: { - type: 'select', - label: 'Overlay Darkness', - description: 'How dark the text background pane is', - options: [ - { value: 'light', label: 'Light' }, - { value: 'medium', label: 'Medium' }, - { value: 'dark', label: 'Dark' } - ], - default: 'medium' - }, - buttons: { - type: 'buttons-editor', - label: 'CTA Buttons', - description: 'Call-to-action buttons with page links', - default: [] - } - }, - minSize: { width: 400, height: 300 }, - resizable: true, - tags: ['video', 'banner', 'hero', 'landing'] - } - }); - - // Category Feed Widget - widgetRegistry.register({ - component: CategoryFeedWidget, - metadata: { - id: 'category-feed', - name: translate('Category Feed'), - category: 'display', - description: translate('Display a filtered feed for a specific category with heading and content type filter'), - icon: Monitor, - defaultProps: { - categoryId: '', - heading: '', - headingLevel: 'h2', - filterType: undefined, - showCategoryName: false, - sortBy: 'latest', - viewMode: 'grid', - showCategories: false, - userId: '', - showSortBar: true, - showLayoutToggles: true, - showFooter: false, - showTitle: true, - showDescription: false, - center: false, - columns: 'auto', - variables: {} - }, - configSchema: { - categoryId: { - type: 'categoryPicker', - label: 'Category', - description: 'Select a category to filter the feed', - default: '' - }, - heading: { - type: 'text', - label: 'Heading', - description: 'Section heading displayed above the feed (overrides category name)', - default: '' - }, - showCategoryName: { - type: 'boolean', - label: 'Show Category Name', - description: 'Display the selected category name as heading (when no custom heading is set)', - default: false - }, - headingLevel: { - type: 'select', - label: 'Heading Level', - description: 'HTML heading level', - options: [ - { value: 'h1', label: 'H1' }, - { value: 'h2', label: 'H2' }, - { value: 'h3', label: 'H3' }, - { value: 'h4', label: 'H4' } - ], - default: 'h2' - }, - filterType: { - type: 'select', - label: 'Content Type', - description: 'Filter to show only a specific content type', - options: [ - { value: 'all', label: 'All' }, - { value: 'posts', label: 'Posts' }, - { value: 'pages', label: 'Pages' }, - { value: 'pictures', label: 'Pictures' } - ], - default: 'all' - }, - sortBy: { - type: 'select', - label: 'Sort By', - description: 'Default sort order for the feed', - options: [ - { value: 'latest', label: 'Latest' }, - { value: 'top', label: 'Top' } - ], - default: 'latest' - }, - viewMode: { - type: 'select', - label: 'View Mode', - description: 'Default display mode for the feed', - options: [ - { value: 'grid', label: 'Grid' }, - { value: 'large', label: 'Large Gallery' }, - { value: 'list', label: 'List' } - ], - default: 'grid' - }, - showCategories: { - type: 'boolean', - label: 'Show Categories Sidebar', - description: 'Show the category tree sidebar on desktop', - default: false - }, - userId: { - type: 'userPicker', - label: 'User Filter', - description: 'Filter feed to show only content from a specific user', - default: '' - }, - showSortBar: { - type: 'boolean', - label: 'Show Sort Bar', - description: 'Show the sort and category toggle bar', - default: true - }, - showLayoutToggles: { - type: 'boolean', - label: 'Show Layout Toggles', - description: 'Show grid/large/list view toggle buttons', - default: true - }, - showFooter: { - type: 'boolean', - label: 'Show Footer', - description: 'Show the site footer below the feed', - default: false - }, - showTitle: { - type: 'boolean', - label: 'Show Title', - description: 'Display the picture/post title', - default: true - }, - showDescription: { - type: 'boolean', - label: 'Show Description', - description: 'Display the picture/post description beneath the title', - default: false - }, - center: { - type: 'boolean', - label: 'Center Content', - description: 'Center the widget content with a maximum width container', - default: false - }, - columns: { - type: 'select', - label: 'Grid Columns', - description: 'Number of columns to display in grid view', - options: [ - { value: 'auto', label: 'Auto (Responsive)' }, - { value: '1', label: '1 Column' }, - { value: '2', label: '2 Columns' }, - { value: '3', label: '3 Columns' }, - { value: '4', label: '4 Columns' }, - { value: '5', label: '5 Columns' }, - { value: '6', label: '6 Columns' } - ], - default: 'auto' - } - }, - minSize: { width: 400, height: 400 }, - resizable: true, - tags: ['category', 'feed', 'home', 'filter', 'section'] - } - }); - - // Menu Widget - widgetRegistry.register({ - component: MenuWidget, - metadata: { - id: 'menu-widget', - name: translate('Menu'), - description: translate('Navigation menu with custom, page, and category links'), - icon: Layout, - category: 'navigation', - defaultProps: { - items: [], - orientation: 'horizontal', - size: 'md', - align: 'left', - variant: 'plain', - padding: 'none', - margin: 'none', - bg: 'none', - bgFrom: '#3b82f6', - bgTo: '#8b5cf6', - variables: {} - }, - configSchema: { - orientation: { - type: 'select', - label: 'Orientation', - description: 'Menu layout direction', - options: [ - { value: 'horizontal', label: 'Horizontal' }, - { value: 'vertical', label: 'Vertical' } - ], - default: 'horizontal' - }, - size: { - type: 'select', - label: 'Size', - description: 'Size of menu items', - options: [ - { value: 'sm', label: 'Small' }, - { value: 'md', label: 'Medium' }, - { value: 'lg', label: 'Large' }, - { value: 'xl', label: 'Extra Large' } - ], - default: 'md' - }, - align: { - type: 'select', - label: 'Alignment', - description: 'Horizontal alignment of menu items', - options: [ - { value: 'left', label: 'Left' }, - { value: 'center', label: 'Center' }, - { value: 'right', label: 'Right' } - ], - default: 'left' - }, - variant: { - type: 'select', - label: 'Style', - description: 'Visual style of the menu', - options: [ - { value: 'plain', label: 'Plain' }, - { value: 'bar', label: 'Bar (Solid Background)' }, - { value: 'glass', label: 'Glass (Blur Effect)' }, - { value: 'pill', label: 'Pill (Rounded Items)' }, - { value: 'underline', label: 'Underline' } - ], - default: 'plain' - }, - padding: { - type: 'select', - label: 'Padding', - description: 'Inner spacing around menu items', - options: [ - { value: 'none', label: 'None' }, - { value: 'xs', label: 'XS' }, - { value: 'sm', label: 'Small' }, - { value: 'md', label: 'Medium' }, - { value: 'lg', label: 'Large' }, - { value: 'xl', label: 'XL' } - ], - default: 'none' - }, - margin: { - type: 'select', - label: 'Margin', - description: 'Outer spacing around the menu', - options: [ - { value: 'none', label: 'None' }, - { value: 'xs', label: 'XS' }, - { value: 'sm', label: 'Small' }, - { value: 'md', label: 'Medium' }, - { value: 'lg', label: 'Large' }, - { value: 'xl', label: 'XL' } - ], - default: 'none' - }, - bg: { - type: 'select', - label: 'Background', - description: 'Background color or gradient for the full row', - options: [ - { value: 'none', label: 'None (Transparent)' }, - { value: 'muted', label: 'Muted' }, - { value: 'dark', label: 'Dark' }, - { value: 'primary', label: 'Primary' }, - { value: 'accent', label: 'Accent' }, - { value: 'gradient-primary', label: 'Gradient — Primary' }, - { value: 'gradient-dark', label: 'Gradient — Dark' }, - { value: 'gradient-ocean', label: 'Gradient — Ocean' }, - { value: 'gradient-sunset', label: 'Gradient — Sunset' }, - { value: 'gradient-forest', label: 'Gradient — Forest' }, - { value: 'gradient-brand', label: 'Gradient — Brand (Amber)' }, - { value: 'custom', label: 'Custom Gradient…' } - ], - default: 'none' - }, - bgFrom: { - type: 'color', - label: 'Gradient Start', - description: 'Left / start color of the custom gradient', - default: '#3b82f6', - showWhen: { field: 'bg', value: 'custom' } - }, - bgTo: { - type: 'color', - label: 'Gradient End', - description: 'Right / end color of the custom gradient', - default: '#8b5cf6', - showWhen: { field: 'bg', value: 'custom' } - } - }, - minSize: { width: 200, height: 40 }, - resizable: true, - tags: ['menu', 'navigation', 'links', 'nav'] - } - }); - - // Competitors Map Widget - widgetRegistry.register({ - component: CompetitorsMapWidget, - metadata: { - id: 'competitors-map', - name: translate('Competitors Map'), - category: 'custom', - description: translate('Interactive map with clustering, regional analysis, and grid search simulator.'), - icon: MapIcon, - defaultProps: { - jobId: '', - enableSimulator: true, - enableRuler: true, - enableInfoPanel: true, - enableLayerToggles: true, - showRegions: true, - showSettings: true, - showLocations: true, - posterMode: false, - enableLocationDetails: true, - maxHeight: '800px', - preset: 'Minimal', - variables: {} - }, - configSchema: { - preset: { - type: 'select', - label: 'Display Preset', - description: 'Choose between full search view or minimal dashboard view.', - options: [ - { value: 'SearchView', label: 'Search View (Full)' }, - { value: 'Minimal', label: 'Minimal (Dashboard)' } - ], - default: 'Minimal' - }, - enableSimulator: { - type: 'boolean', - label: 'Enable Grid Simulator', - description: 'Show grid search simulator and regional playback tools.', - default: true - }, - enableRuler: { - type: 'boolean', - label: 'Enable Distance Ruler', - description: 'Show tool for measuring distances on the map.', - default: true - }, - enableInfoPanel: { - type: 'boolean', - label: 'Enable Statistics Panel', - description: 'Show summary statistics for the visible map area.', - default: true - }, - enableLayerToggles: { - type: 'boolean', - label: 'Enable Layer Toggles', - description: 'Allow toggling between density heatmap and center points.', - default: true - }, - targetLocation: { - type: 'locationPicker', - label: 'Map Target Location', - description: 'Pick the starting point or administrative region for the map.', - }, - showRegions: { - type: 'boolean', - label: 'Show GADM Regions', - description: 'Display red/green boundary polygons for searched regions.', - default: true - }, - showSettings: { - type: 'boolean', - label: 'Show Grid Simulator', - description: 'Display density points and grid centers from the simulator.', - default: true - }, - showLocations: { - type: 'boolean', - label: 'Show Search Results', - description: 'Display the actual found location pins and clusters.', - default: true - }, - posterMode: { - type: 'boolean', - label: 'Poster Mode', - description: 'Display the map in stylized poster view with themed overlays.', - default: false - }, - enableLocationDetails: { - type: 'boolean', - label: 'Allow Location Details', - description: 'Click on a location to open the detail panel with address, ratings, and contact info.', - default: true - }, - maxHeight: { - type: 'string', - label: 'Max Height', - description: 'Maximum height of the map widget (e.g. 800px, 100vh, none).', - default: '800px' - } - }, - minSize: { width: 400, height: 400 }, - resizable: true, - tags: ['map', 'places', 'competitors', 'search', 'simulator'] - } - }); - -} +import { lazy } from 'react'; +import { widgetRegistry } from './widgetRegistry'; +import { translate } from '@/i18n'; +import { + Monitor, + Layout, + FileText, + Code, + Video, + MessageSquare, + Map as MapIcon, +} from 'lucide-react'; + +import type { + HtmlWidgetProps, + PhotoGridProps, + PhotoCardWidgetProps, + PhotoGridWidgetProps, + TabsWidgetProps, + GalleryWidgetProps, + PageCardWidgetProps, + MarkdownTextWidgetProps, + LayoutContainerWidgetProps, + FileBrowserWidgetProps, +} from '@polymech/shared'; + +import PageCardWidget from '@/modules/pages/PageCardWidget'; +import PhotoGrid from '@/components/PhotoGrid'; +import PhotoGridWidget from '@/components/widgets/PhotoGridWidget'; +import PhotoCardWidget from '@/components/widgets/PhotoCardWidget'; +import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget'; +import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget'; +import GalleryWidget from '@/components/widgets/GalleryWidget'; +import TabsWidget from '@/components/widgets/TabsWidget'; +import { HtmlWidget } from '@/components/widgets/HtmlWidget'; +import HomeWidget from '@/components/widgets/HomeWidget'; +import VideoBannerWidget from '@/components/widgets/VideoBannerWidget'; +import CategoryFeedWidget from '@/components/widgets/CategoryFeedWidget'; +import MenuWidget from '@/components/widgets/MenuWidget'; + +const SupportChatWidget = lazy(() => import('@/components/widgets/SupportChatWidget')); +const CompetitorsMapWidget = lazy(() => import('@/components/widgets/CompetitorsMapWidget')); +const FileBrowserWidget = lazy(() => import('@/modules/storage/FileBrowserWidget').then(m => ({ default: m.FileBrowserWidget }))); + +export function registerAllWidgets() { + // Clear existing registrations (useful for HMR) + widgetRegistry.clear(); + + // HTML Widget + widgetRegistry.register({ + component: HtmlWidget, + metadata: { + id: 'html-widget', + name: translate('HTML Content'), + category: 'display', + description: translate('Render HTML content with variable substitution'), + icon: Code, + defaultProps: { + content: '
\n

Hello ${name}

\n

Welcome to our custom widget!

\n
', + variables: '{\n "name": "World"\n}', + className: '' + }, + configSchema: { + content: { + type: 'markdown', // Using markdown editor for larger text area + label: 'HTML Content', + description: 'Enter your HTML code here. Use ${varName} for substitutions.', + default: '
Hello World
' + }, + variables: { + type: 'markdown', // Using markdown/textarea for JSON input for now + label: 'Variables (JSON)', + description: 'JSON object defining variables for substitution.', + default: '{}' + }, + className: { + type: 'classname', + label: 'CSS Class', + description: 'Tailwind classes for the container', + default: '' + } + }, + minSize: { width: 300, height: 100 }, + resizable: true, + tags: ['html', 'code', 'custom', 'embed'] + } + }); + + // Photo widgets + widgetRegistry.register({ + component: PhotoGrid, + metadata: { + id: 'photo-grid', + name: translate('Photo Grid'), + category: 'custom', + description: translate('Display photos in a responsive grid layout'), + icon: Monitor, + defaultProps: { + variables: {} + }, + // Note: PhotoGrid fetches data internally based on navigation context + // For configurable picture selection, use 'photo-grid-widget' instead + minSize: { width: 300, height: 200 }, + resizable: true, + tags: ['photo', 'grid', 'gallery'] + } + }); + + widgetRegistry.register({ + component: PhotoCardWidget, + metadata: { + id: 'photo-card', + name: translate('Photo Card'), + category: 'custom', + description: translate('Display a single photo card with details'), + icon: Monitor, + defaultProps: { + pictureId: null, + postId: null, + showHeader: true, + showFooter: true, + showAuthor: true, + showActions: true, + showTitle: true, + showDescription: true, + contentDisplay: 'below', + imageFit: 'contain', + variables: {} + }, + configSchema: { + pictureId: { + type: 'imagePicker', + label: 'Select Picture', + description: 'Choose a picture from your published images', + default: null + }, + showHeader: { + type: 'boolean', + label: 'Show Header', + description: 'Show header with author information', + default: true + }, + showFooter: { + type: 'boolean', + label: 'Show Footer', + description: 'Show footer with likes, comments, and actions', + default: true + }, + showAuthor: { + type: 'boolean', + label: 'Include Author', + description: 'Show author avatar and name', + default: true + }, + showActions: { + type: 'boolean', + label: 'Include Actions', + description: 'Show like, comment, download buttons', + default: true + }, + showTitle: { + type: 'boolean', + label: 'Show Title', + description: 'Display the picture title', + default: true + }, + showDescription: { + type: 'boolean', + label: 'Show Description', + description: 'Display the picture description', + default: true + }, + contentDisplay: { + type: 'select', + label: 'Content Display', + description: 'How to display title and description', + options: [ + { value: 'below', label: 'Below Image' }, + { value: 'overlay', label: 'Overlay on Hover' }, + { value: 'overlay-always', label: 'Overlay (Always)' } + ], + default: 'below' + }, + imageFit: { + type: 'select', + label: 'Image Fit', + description: 'How the image should fit within the card', + options: [ + { value: 'contain', label: 'Contain' }, + { value: 'cover', label: 'Cover' } + ], + default: 'contain' + } + }, + minSize: { width: 300, height: 400 }, + resizable: true, + tags: ['photo', 'card', 'display'] + } + }); + + widgetRegistry.register({ + component: PhotoGridWidget, + metadata: { + id: 'photo-grid-widget', + name: translate('Photo Grid Widget'), + category: 'custom', + description: translate('Display a customizable grid of selected photos'), + icon: Monitor, + defaultProps: { + pictureIds: [], + columns: 'auto', + variables: {} + }, + configSchema: { + pictureIds: { + type: 'imagePicker', // We'll need to upgrade the config renderer to handle array/multi-select if not already supported, or rely on internal EditMode + label: 'Select Pictures', + description: 'Choose pictures to display in the grid', + default: [] + }, + columns: { + type: 'select', + label: 'Grid Columns', + description: 'Number of columns to display in grid view', + options: [ + { value: 'auto', label: 'Auto (Responsive)' }, + { value: '1', label: '1 Column' }, + { value: '2', label: '2 Columns' }, + { value: '3', label: '3 Columns' }, + { value: '4', label: '4 Columns' }, + { value: '5', label: '5 Columns' }, + { value: '6', label: '6 Columns' } + ], + default: 'auto' + } + }, + minSize: { width: 300, height: 300 }, + resizable: true, + tags: ['photo', 'grid', 'gallery', 'custom'] + } + }); + + widgetRegistry.register({ + component: TabsWidget, + metadata: { + id: 'tabs-widget', + name: translate('Tabs Widget'), + category: 'layout', + description: translate('Organize content into switchable tabs'), + icon: Layout, + defaultProps: { + tabs: [ + { + id: 'tab-1', + label: 'Tab 1', + layoutId: 'tab-layout-1', + layoutData: { + id: 'tab-layout-1', + name: 'Tab 1', + containers: [], + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0' + } + }, + { + id: 'tab-2', + label: 'Tab 2', + layoutId: 'tab-layout-2', + layoutData: { + id: 'tab-layout-2', + name: 'Tab 2', + containers: [], + createdAt: Date.now(), + updatedAt: Date.now(), + version: '1.0.0' + } + } + ], + orientation: 'horizontal', + tabBarPosition: 'top', + tabBarClassName: '', + contentClassName: '', + variables: {} + } as unknown as TabsWidgetProps, + + configSchema: { + tabs: { + type: 'tabs-editor', + label: 'Tabs', + description: 'Manage tabs and their order', + default: [] + }, + tabBarPosition: { + type: 'select', + label: 'Tab Bar Position', + description: 'Position of the tab bar', + options: [ + { value: 'top', label: 'Top' }, + { value: 'bottom', label: 'Bottom' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' } + ], + default: 'top' + }, + tabBarClassName: { + type: 'classname', + label: 'Tab Bar Style', + description: 'Tailwind classes for the tab bar', + default: '' + }, + contentClassName: { + type: 'classname', + label: 'Content Area Style', + description: 'Tailwind classes for the content area', + default: '' + } + }, + minSize: { width: 400, height: 300 }, + resizable: true, + tags: ['layout', 'tabs', 'container'] + }, + getNestedLayouts: (props: TabsWidgetProps) => { + if (!props.tabs || !Array.isArray(props.tabs)) return []; + return props.tabs.map((tab: unknown) => ({ + id: (tab as any).id, + label: (tab as any).label, + layoutId: (tab as any).layoutId + })); + } + }); + + widgetRegistry.register({ + component: GalleryWidget, + metadata: { + id: 'gallery-widget', + name: translate('Gallery'), + category: 'custom', + description: translate('Interactive gallery with main viewer and filmstrip navigation'), + icon: Monitor, + defaultProps: { + pictureIds: [], + thumbnailLayout: 'strip', + imageFit: 'contain', + thumbnailsPosition: 'bottom', + thumbnailsOrientation: 'horizontal', + zoomEnabled: false, + showVersions: false, + showTitle: false, + showDescription: false, + autoPlayVideos: false, + thumbnailsClassName: '', + variables: {} + }, + configSchema: { + pictureIds: { + type: 'imagePicker', + label: 'Select Pictures', + description: 'Choose pictures to display in the gallery', + default: [], + multiSelect: true + }, + showTitle: { + type: 'boolean', + label: 'Show Title', + description: 'Display the title of each picture below the main image', + default: false + }, + showDescription: { + type: 'boolean', + label: 'Show Description', + description: 'Display the description of each picture below the main image', + default: false + }, + thumbnailLayout: { + type: 'select', + label: 'Thumbnail Layout', + description: 'How to display thumbnails below the main image', + options: [ + { value: 'strip', label: 'Horizontal Strip (scrollable)' }, + { value: 'grid', label: 'Grid (multiple rows, fit width)' } + ], + default: 'strip' + }, + imageFit: { + type: 'select', + label: 'Main Image Fit', + description: 'How the main image should fit within its container', + options: [ + { value: 'contain', label: 'Contain (fit within bounds, show full image)' }, + { value: 'cover', label: 'Cover (fill container, may crop image)' } + ], + default: 'cover' + }, + thumbnailsPosition: { + type: 'select', + label: 'Thumbnails Position', + description: 'Where to place the thumbnails relative to the main image', + options: [ + { value: 'bottom', label: 'Bottom' }, + { value: 'top', label: 'Top' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' } + ], + default: 'bottom' + }, + thumbnailsOrientation: { + type: 'select', + label: 'Thumbnails Orientation', + description: 'Direction of the thumbnail strip', + options: [ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' } + ], + default: 'horizontal' + }, + zoomEnabled: { + type: 'boolean', + label: 'Enable Pan Zoom', + description: 'Enlarge image and pan on hover', + default: false + }, + showVersions: { + type: 'boolean', + label: 'Show Versions', + description: 'Show version navigation in the filmstrip for pictures with versions', + default: false + }, + autoPlayVideos: { + type: 'boolean', + label: 'Auto Play Videos', + description: 'Automatically play video items when selected (muted)', + default: false + }, + thumbnailsClassName: { + type: 'classname', + label: 'Thumbnails CSS Class', + description: 'Additional CSS classes for the thumbnail strip container', + default: '' + } + }, + minSize: { width: 600, height: 500 }, + resizable: true, + tags: ['photo', 'gallery', 'viewer', 'slideshow'] + } + }); + + widgetRegistry.register({ + component: PageCardWidget, + metadata: { + id: 'page-card', + name: translate('Page Card'), + category: 'custom', + description: translate('Display a single page card with details'), + icon: FileText, + defaultProps: { + pageId: null, + showHeader: true, + showFooter: true, + showAuthor: true, + showActions: true, + contentDisplay: 'below', + variables: {} + }, + configSchema: { + pageId: { + type: 'pagePicker', + label: 'Select Page', + description: 'Choose a page to display', + default: null + }, + showHeader: { + type: 'boolean', + label: 'Show Header', + description: 'Show header with author information', + default: true + }, + showFooter: { + type: 'boolean', + label: 'Show Footer', + description: 'Show footer with likes, comments, and actions', + default: true + }, + showAuthor: { + type: 'boolean', + label: 'Show Author', + description: 'Show author avatar and name', + default: true + }, + showActions: { + type: 'boolean', + label: 'Show Actions', + description: 'Show like and comment buttons', + default: true + }, + contentDisplay: { + type: 'select', + label: 'Content Display', + description: 'How to display title and description', + options: [ + { value: 'below', label: 'Below Image' }, + { value: 'overlay', label: 'Overlay on Hover' }, + { value: 'overlay-always', label: 'Overlay (Always)' } + ], + default: 'below' + } + }, + minSize: { width: 300, height: 400 }, + resizable: true, + tags: ['page', 'card', 'display'] + } + }); + + // Content widgets + widgetRegistry.register({ + component: MarkdownTextWidget, + metadata: { + id: 'markdown-text', + name: translate('Text Block'), + category: 'display', + description: translate('Add rich text content with Markdown support'), + icon: FileText, + defaultProps: { + content: '', + placeholder: 'Enter your text here...', + variables: {} + }, + configSchema: { + placeholder: { + type: 'text', + label: 'Placeholder Text', + description: 'Text shown when content is empty', + default: 'Enter your text here...' + } + }, + minSize: { width: 300, height: 150 }, + resizable: true, + tags: ['text', 'markdown', 'content', 'editor'] + } + }); + + widgetRegistry.register({ + component: LayoutContainerWidget, + metadata: { + id: 'layout-container-widget', + name: translate('Nested Layout Container'), + category: 'custom', + description: translate('A widget that contains its own independent layout canvas.'), + icon: Layout, + defaultProps: { + nestedPageName: 'Nested Container', + showControls: true, + variables: {} + }, + configSchema: { + nestedPageName: { + type: 'text', + label: 'Canvas Name', + description: 'The display name for the nested layout canvas.', + default: 'Nested Container' + }, + showControls: { + type: 'boolean', + label: 'Show Canvas Controls', + description: 'Show the main controls (Add Container, Save, etc.) inside this nested canvas.', + default: true + } + }, + minSize: { width: 300, height: 200 }, + resizable: true, + tags: ['layout', 'container', 'nested', 'canvas'] + }, + getNestedLayouts: (props: LayoutContainerWidgetProps) => { + if (props.nestedPageId) { + return [{ + id: 'nested-container', + label: props.nestedPageName || 'Nested Container', + layoutId: props.nestedPageId + }]; + } + return []; + } + }); + + widgetRegistry.register({ + component: SupportChatWidget, + metadata: { + id: 'support-chat-widget', + name: translate('Support Chat Widget'), + category: 'custom', + description: translate('Floating support chat button that expands into a full chat panel.'), + icon: MessageSquare, + defaultProps: { + buttons: [{ id: 'default', label: 'Ask Questions', contextPrompt: '' }], + position: 'bottom-right', + mode: 'floating', + autoOpen: false, + variables: {} + }, + configSchema: { + position: { + type: 'select', + label: 'Button Position (Floating)', + description: 'Screen corner position (Only applies if Mode is Floating).', + options: [ + { value: 'bottom-right', label: 'Bottom Right' }, + { value: 'bottom-left', label: 'Bottom Left' } + ], + default: 'bottom-right' + }, + mode: { + type: 'select', + label: 'Display Mode', + description: 'Whether the chat button floats over the page or renders inline within the layout.', + options: [ + { value: 'floating', label: 'Floating (Corner)' }, + { value: 'inline', label: 'Inline (In Layout)' } + ], + default: 'floating' + }, + autoOpen: { + type: 'boolean', + label: 'Auto Open', + description: 'Open automatically when the page loads.', + default: false + } + }, + minSize: { width: 100, height: 100 }, + resizable: false, + tags: ['chat', 'support', 'ai', 'floating', 'button'] + } + }); + + // File Browser Widget + widgetRegistry.register({ + component: FileBrowserWidget, + metadata: { + id: 'file-browser', + name: translate('File Browser'), + category: 'custom', + description: translate('Browse files and directories on VFS mounts'), + icon: Monitor, + defaultProps: { + mount: 'root', + path: '/', + glob: '*.*', + mode: 'simple', + viewMode: 'list', + sortBy: 'name', + showToolbar: true, + canChangeMount: true, + allowFileViewer: true, + allowLightbox: true, + allowDownload: true, + allowPreview: false, + initialFile: '', + jail: false, + minHeight: '600px', + showStatusBar: true, + searchQuery: '', + variables: {} + }, + configSchema: { + mountAndPath: { + type: 'vfsPicker', + label: 'Mount & Initial Path', + description: 'Browse to select the mount and starting directory', + mountKey: 'mount', + pathKey: 'path', + defaultMount: 'home', + }, + searchQuery: { + type: 'text', + label: 'Initial Search Query', + description: 'If set, the file browser will start in search mode matching this query', + default: '' + }, + initialFile: { + type: 'text', + label: 'Initial File', + description: 'If set, automatically selects and opens this file on load (needs filename + extension)', + default: '' + }, + glob: { + type: 'selectWithText', + label: 'File Filter', + description: 'Filter which files are shown', + options: [ + { value: '*.*', label: 'All Files' }, + { value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif,mp4,webm,mov,avi,mkv}', label: 'Media (Images & Video)' }, + { value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif}', label: 'Images Only' }, + { value: '*.{mp4,webm,mov,avi,mkv,flv}', label: 'Videos Only' }, + ], + default: '*.*' + }, + mode: { + type: 'select', + label: 'Mode', + description: 'Simple = browse only. Advanced = file detail panel.', + options: [ + { value: 'simple', label: 'Simple' }, + { value: 'advanced', label: 'Advanced' } + ], + default: 'simple' + }, + viewMode: { + type: 'select', + label: 'View Mode', + description: 'List, thumbnail grid or tree view', + options: [ + { value: 'list', label: 'List' }, + { value: 'thumbs', label: 'Thumbnails' }, + { value: 'tree', label: 'Tree' } + ], + default: 'list' + }, + sortBy: { + type: 'select', + label: 'Sort By', + description: 'Default sort order', + options: [ + { value: 'name', label: 'Name' }, + { value: 'ext', label: 'Extension' }, + { value: 'date', label: 'Date' }, + { value: 'type', label: 'Type' } + ], + default: 'name' + }, + showToolbar: { + type: 'boolean', + label: 'Show Toolbar', + description: 'Show navigation toolbar with breadcrumbs, sort, and view toggle', + default: true + }, + canChangeMount: { + type: 'boolean', + label: 'Allow Mount Switching', + description: 'Let users switch between VFS mounts', + default: true + }, + allowLightbox: { + type: 'boolean', + label: 'Allow Image/Video Lightbox', + description: 'Open images and videos in a fullscreen lightbox', + default: true + }, + allowFileViewer: { + type: 'boolean', + label: 'Allow Text File Viewer', + description: 'Open text/code files in the built-in viewer', + default: true + }, + allowDownload: { + type: 'boolean', + label: 'Allow Download', + description: 'Show download button for files', + default: true + }, + allowPreview: { + type: 'boolean', + label: 'Allow Document & 3D Preview', + description: 'Open document and 3D files in a modal preview', + default: true + }, + jail: { + type: 'boolean', + label: 'Jail Mode', + description: 'Prevent navigating above the configured mount and path', + default: false + }, + minHeight: { + type: 'selectWithText', + label: 'Min Height', + description: 'Minimum height of the widget (e.g. 600px, 70vh, 50%)', + options: [ + { value: '400px', label: '400px' }, + { value: '500px', label: '500px' }, + { value: '600px', label: '600px' }, + { value: '800px', label: '800px' }, + { value: '50vh', label: '50vh' }, + { value: '70vh', label: '70vh' }, + ], + default: '600px' + }, + showStatusBar: { + type: 'boolean', + label: 'Show Status Bar', + description: 'Show item count and path info at the bottom', + default: true + } + }, + minSize: { width: 300, height: 300 }, + resizable: true, + tags: ['file', 'browser', 'vfs', 'storage', 'explorer'] + } + }); + + // Home Widget + widgetRegistry.register({ + component: HomeWidget, + metadata: { + id: 'home', + name: translate('Home Feed'), + category: 'display', + description: translate('Display the main home feed with photos, categories, and sorting'), + icon: Monitor, + defaultProps: { + sortBy: 'latest', + viewMode: 'grid', + showCategories: false, + categorySlugs: '', + userId: '', + showSortBar: true, + showLayoutToggles: true, + showFooter: true, + center: false, + searchQuery: '', + initialContentType: '', + showAuthor: true, + showSocial: true, + variables: {} + }, + configSchema: { + sortBy: { + type: 'select', + label: 'Sort By', + description: 'Default sort order for the feed', + options: [ + { value: 'latest', label: 'Latest' }, + { value: 'top', label: 'Top' } + ], + default: 'latest' + }, + viewMode: { + type: 'select', + label: 'View Mode', + description: 'Default display mode for the feed', + options: [ + { value: 'grid', label: 'Grid' }, + { value: 'large', label: 'Large Gallery' }, + { value: 'list', label: 'List' } + ], + default: 'grid' + }, + showCategories: { + type: 'boolean', + label: 'Show Categories Sidebar', + description: 'Show the category tree sidebar on desktop', + default: false + }, + categorySlugs: { + type: 'text', + label: 'Category Filter', + description: 'Comma-separated category slugs to filter by (leave empty for all)', + default: '' + }, + userId: { + type: 'userPicker', + label: 'User Filter', + description: 'Filter feed to show only content from a specific user', + default: '' + }, + showSortBar: { + type: 'boolean', + label: 'Show Sort Bar', + description: 'Show the sort and category toggle bar', + default: true + }, + showLayoutToggles: { + type: 'boolean', + label: 'Show Layout Toggles', + description: 'Show grid/large/list view toggle buttons', + default: true + }, + showFooter: { + type: 'boolean', + label: 'Show Footer', + description: 'Show the site footer below the feed', + default: true + }, + showAuthor: { + type: 'boolean', + label: 'Show Author', + description: 'Show author avatar and name on feed cards', + default: true + }, + showSocial: { + type: 'boolean', + label: 'Show Social', + description: 'Show likes and comments on feed cards', + default: true + }, + center: { + type: 'boolean', + label: 'Center Content', + description: 'Center the widget content with a maximum width container', + default: false + }, + searchQuery: { + type: 'text', + label: 'Initial Search Query', + description: 'Load feed matching this search query', + default: '' + }, + initialContentType: { + type: 'select', + label: 'Content Type', + description: 'Filter by content type', + options: [ + { value: 'all', label: 'All Content' }, + { value: 'posts', label: 'Posts' }, + { value: 'pages', label: 'Pages' }, + { value: 'pictures', label: 'Pictures' }, + { value: 'files', label: 'Files' } + ], + default: 'all' + } + }, + minSize: { width: 400, height: 400 }, + resizable: true, + tags: ['home', 'feed', 'gallery', 'photos', 'categories'] + } + }); + + // Video Banner Widget + widgetRegistry.register({ + component: VideoBannerWidget, + metadata: { + id: 'video-banner', + name: translate('Hero'), + category: 'display', + description: translate('Full-width hero banner with background video, text overlay, and call-to-action buttons'), + icon: Video, + defaultProps: { + videoId: null, + posterImageId: null, + backgroundImageId: null, + heading: '', + description: '', + minHeight: 500, + overlayOpacity: 'medium', + objectFit: 'cover', + buttons: [], + variables: {} + }, + configSchema: { + videoId: { + type: 'imagePicker', + label: 'Background Video', + description: 'Select a video-intern picture to use as background', + default: null + }, + backgroundImageId: { + type: 'imagePicker', + label: 'Background Image', + description: 'Static image to use as background (when no video is set)', + default: null + }, + posterImageId: { + type: 'imagePicker', + label: 'Poster Image', + description: 'Fallback image shown while video loads', + default: null + }, + objectFit: { + type: 'select', + label: 'Image Fit', + description: 'How the background image fills the banner', + options: [ + { value: 'cover', label: 'Cover (fill, may crop)' }, + { value: 'contain', label: 'Contain (fit, may letterbox)' } + ], + default: 'cover' + }, + heading: { + type: 'text', + label: 'Heading', + description: 'Main banner heading text', + default: '' + }, + description: { + type: 'text', + label: 'Description', + description: 'Banner description text', + default: '' + }, + minHeight: { + type: 'number', + label: 'Minimum Height (px)', + description: 'Minimum height of the banner in pixels', + default: 500 + }, + overlayOpacity: { + type: 'select', + label: 'Overlay Darkness', + description: 'How dark the text background pane is', + options: [ + { value: 'light', label: 'Light' }, + { value: 'medium', label: 'Medium' }, + { value: 'dark', label: 'Dark' } + ], + default: 'medium' + }, + buttons: { + type: 'buttons-editor', + label: 'CTA Buttons', + description: 'Call-to-action buttons with page links', + default: [] + } + }, + minSize: { width: 400, height: 300 }, + resizable: true, + tags: ['video', 'banner', 'hero', 'landing'] + } + }); + + // Category Feed Widget + widgetRegistry.register({ + component: CategoryFeedWidget, + metadata: { + id: 'category-feed', + name: translate('Category Feed'), + category: 'display', + description: translate('Display a filtered feed for a specific category with heading and content type filter'), + icon: Monitor, + defaultProps: { + categoryId: '', + heading: '', + headingLevel: 'h2', + filterType: undefined, + showCategoryName: false, + sortBy: 'latest', + viewMode: 'grid', + showCategories: false, + userId: '', + showSortBar: true, + showLayoutToggles: true, + showFooter: false, + showTitle: true, + showDescription: false, + showAuthor: true, + showSocial: true, + center: false, + columns: 'auto', + variables: {} + }, + configSchema: { + categoryId: { + type: 'categoryPicker', + label: 'Category', + description: 'Select a category to filter the feed', + default: '' + }, + heading: { + type: 'text', + label: 'Heading', + description: 'Section heading displayed above the feed (overrides category name)', + default: '' + }, + showCategoryName: { + type: 'boolean', + label: 'Show Category Name', + description: 'Display the selected category name as heading (when no custom heading is set)', + default: false + }, + headingLevel: { + type: 'select', + label: 'Heading Level', + description: 'HTML heading level', + options: [ + { value: 'h1', label: 'H1' }, + { value: 'h2', label: 'H2' }, + { value: 'h3', label: 'H3' }, + { value: 'h4', label: 'H4' } + ], + default: 'h2' + }, + filterType: { + type: 'select', + label: 'Content Type', + description: 'Filter to show only a specific content type', + options: [ + { value: 'all', label: 'All' }, + { value: 'posts', label: 'Posts' }, + { value: 'pages', label: 'Pages' }, + { value: 'pictures', label: 'Pictures' } + ], + default: 'all' + }, + sortBy: { + type: 'select', + label: 'Sort By', + description: 'Default sort order for the feed', + options: [ + { value: 'latest', label: 'Latest' }, + { value: 'top', label: 'Top' } + ], + default: 'latest' + }, + viewMode: { + type: 'select', + label: 'View Mode', + description: 'Default display mode for the feed', + options: [ + { value: 'grid', label: 'Grid' }, + { value: 'large', label: 'Large Gallery' }, + { value: 'list', label: 'List' } + ], + default: 'grid' + }, + showCategories: { + type: 'boolean', + label: 'Show Categories Sidebar', + description: 'Show the category tree sidebar on desktop', + default: false + }, + userId: { + type: 'userPicker', + label: 'User Filter', + description: 'Filter feed to show only content from a specific user', + default: '' + }, + showSortBar: { + type: 'boolean', + label: 'Show Sort Bar', + description: 'Show the sort and category toggle bar', + default: true + }, + showLayoutToggles: { + type: 'boolean', + label: 'Show Layout Toggles', + description: 'Show grid/large/list view toggle buttons', + default: true + }, + showFooter: { + type: 'boolean', + label: 'Show Footer', + description: 'Show the site footer below the feed', + default: false + }, + showTitle: { + type: 'boolean', + label: 'Show Title', + description: 'Display the picture/post title', + default: true + }, + showDescription: { + type: 'boolean', + label: 'Show Description', + description: 'Display the picture/post description beneath the title', + default: false + }, + showAuthor: { + type: 'boolean', + label: 'Show Author', + description: 'Show author avatar and name on feed cards', + default: true + }, + showSocial: { + type: 'boolean', + label: 'Show Social', + description: 'Show likes and comments on feed cards', + default: true + }, + center: { + type: 'boolean', + label: 'Center Content', + description: 'Center the widget content with a maximum width container', + default: false + }, + columns: { + type: 'select', + label: 'Grid Columns', + description: 'Number of columns to display in grid view', + options: [ + { value: 'auto', label: 'Auto (Responsive)' }, + { value: '1', label: '1 Column' }, + { value: '2', label: '2 Columns' }, + { value: '3', label: '3 Columns' }, + { value: '4', label: '4 Columns' }, + { value: '5', label: '5 Columns' }, + { value: '6', label: '6 Columns' } + ], + default: 'auto' + } + }, + minSize: { width: 400, height: 400 }, + resizable: true, + tags: ['category', 'feed', 'home', 'filter', 'section'] + } + }); + + // Menu Widget + widgetRegistry.register({ + component: MenuWidget, + metadata: { + id: 'menu-widget', + name: translate('Menu'), + description: translate('Navigation menu with custom, page, and category links'), + icon: Layout, + category: 'navigation', + defaultProps: { + items: [], + orientation: 'horizontal', + size: 'md', + align: 'left', + variant: 'plain', + padding: 'none', + margin: 'none', + bg: 'none', + bgFrom: '#3b82f6', + bgTo: '#8b5cf6', + variables: {} + }, + configSchema: { + orientation: { + type: 'select', + label: 'Orientation', + description: 'Menu layout direction', + options: [ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' } + ], + default: 'horizontal' + }, + size: { + type: 'select', + label: 'Size', + description: 'Size of menu items', + options: [ + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + { value: 'xl', label: 'Extra Large' } + ], + default: 'md' + }, + align: { + type: 'select', + label: 'Alignment', + description: 'Horizontal alignment of menu items', + options: [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' } + ], + default: 'left' + }, + variant: { + type: 'select', + label: 'Style', + description: 'Visual style of the menu', + options: [ + { value: 'plain', label: 'Plain' }, + { value: 'bar', label: 'Bar (Solid Background)' }, + { value: 'glass', label: 'Glass (Blur Effect)' }, + { value: 'pill', label: 'Pill (Rounded Items)' }, + { value: 'underline', label: 'Underline' } + ], + default: 'plain' + }, + padding: { + type: 'select', + label: 'Padding', + description: 'Inner spacing around menu items', + options: [ + { value: 'none', label: 'None' }, + { value: 'xs', label: 'XS' }, + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + { value: 'xl', label: 'XL' } + ], + default: 'none' + }, + margin: { + type: 'select', + label: 'Margin', + description: 'Outer spacing around the menu', + options: [ + { value: 'none', label: 'None' }, + { value: 'xs', label: 'XS' }, + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + { value: 'xl', label: 'XL' } + ], + default: 'none' + }, + bg: { + type: 'select', + label: 'Background', + description: 'Background color or gradient for the full row', + options: [ + { value: 'none', label: 'None (Transparent)' }, + { value: 'muted', label: 'Muted' }, + { value: 'dark', label: 'Dark' }, + { value: 'primary', label: 'Primary' }, + { value: 'accent', label: 'Accent' }, + { value: 'gradient-primary', label: 'Gradient — Primary' }, + { value: 'gradient-dark', label: 'Gradient — Dark' }, + { value: 'gradient-ocean', label: 'Gradient — Ocean' }, + { value: 'gradient-sunset', label: 'Gradient — Sunset' }, + { value: 'gradient-forest', label: 'Gradient — Forest' }, + { value: 'gradient-brand', label: 'Gradient — Brand (Amber)' }, + { value: 'custom', label: 'Custom Gradient…' } + ], + default: 'none' + }, + bgFrom: { + type: 'color', + label: 'Gradient Start', + description: 'Left / start color of the custom gradient', + default: '#3b82f6', + showWhen: { field: 'bg', value: 'custom' } + }, + bgTo: { + type: 'color', + label: 'Gradient End', + description: 'Right / end color of the custom gradient', + default: '#8b5cf6', + showWhen: { field: 'bg', value: 'custom' } + } + }, + minSize: { width: 200, height: 40 }, + resizable: true, + tags: ['menu', 'navigation', 'links', 'nav'] + } + }); + + // Competitors Map Widget + widgetRegistry.register({ + component: CompetitorsMapWidget, + metadata: { + id: 'competitors-map', + name: translate('Competitors Map'), + category: 'custom', + description: translate('Interactive map with clustering, regional analysis, and grid search simulator.'), + icon: MapIcon, + defaultProps: { + jobId: '', + enableSimulator: true, + enableRuler: true, + enableInfoPanel: true, + enableLayerToggles: true, + showRegions: true, + showSettings: true, + showLocations: true, + posterMode: false, + enableLocationDetails: true, + maxHeight: '800px', + preset: 'Minimal', + variables: {} + }, + configSchema: { + preset: { + type: 'select', + label: 'Display Preset', + description: 'Choose between full search view or minimal dashboard view.', + options: [ + { value: 'SearchView', label: 'Search View (Full)' }, + { value: 'Minimal', label: 'Minimal (Dashboard)' } + ], + default: 'Minimal' + }, + enableSimulator: { + type: 'boolean', + label: 'Enable Grid Simulator', + description: 'Show grid search simulator and regional playback tools.', + default: true + }, + enableRuler: { + type: 'boolean', + label: 'Enable Distance Ruler', + description: 'Show tool for measuring distances on the map.', + default: true + }, + enableInfoPanel: { + type: 'boolean', + label: 'Enable Statistics Panel', + description: 'Show summary statistics for the visible map area.', + default: true + }, + enableLayerToggles: { + type: 'boolean', + label: 'Enable Layer Toggles', + description: 'Allow toggling between density heatmap and center points.', + default: true + }, + targetLocation: { + type: 'locationPicker', + label: 'Map Target Location', + description: 'Pick the starting point or administrative region for the map.', + }, + showRegions: { + type: 'boolean', + label: 'Show GADM Regions', + description: 'Display red/green boundary polygons for searched regions.', + default: true + }, + showSettings: { + type: 'boolean', + label: 'Show Grid Simulator', + description: 'Display density points and grid centers from the simulator.', + default: true + }, + showLocations: { + type: 'boolean', + label: 'Show Search Results', + description: 'Display the actual found location pins and clusters.', + default: true + }, + posterMode: { + type: 'boolean', + label: 'Poster Mode', + description: 'Display the map in stylized poster view with themed overlays.', + default: false + }, + enableLocationDetails: { + type: 'boolean', + label: 'Allow Location Details', + description: 'Click on a location to open the detail panel with address, ratings, and contact info.', + default: true + }, + maxHeight: { + type: 'string', + label: 'Max Height', + description: 'Maximum height of the map widget (e.g. 800px, 100vh, none).', + default: '800px' + } + }, + minSize: { width: 400, height: 400 }, + resizable: true, + tags: ['map', 'places', 'competitors', 'search', 'simulator'] + } + }); + +} diff --git a/packages/ui/src/modules/pages/PageCard.tsx b/packages/ui/src/modules/pages/PageCard.tsx index 9d1915c9..c6784217 100644 --- a/packages/ui/src/modules/pages/PageCard.tsx +++ b/packages/ui/src/modules/pages/PageCard.tsx @@ -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 { - 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 = ({ - 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 ( -
- -
- -
- - {showContent && ( -
- {/* Author + Actions row */} -
- {(showAuthor ?? preset?.showAuthor) !== false && ( - - )} - {(showActions ?? preset?.showActions) !== false && ( -
- - -
- )} -
- - {/* Title & Description */} -
- {(showTitle ?? preset?.showTitle) !== false && ( -
{title}
- )} - {(showDescription ?? preset?.showDescription) !== false && description && ( -
- -
- )} -
-
- )} -
- ); - } - - // Grid Variant - return ( -
-
- - - {/* TESTING: Entire hover overlay disabled */} - {false && showContent && ( -
-
-
- {preset?.showAuthor !== false && ( - - )} - - {preset?.showActions !== false && ( -
- -
- )} -
-
-
- )} -
- {/* Info bar below image (preset-driven) */} - {(preset?.showTitle || preset?.showDescription) && (title || description) && ( -
- {preset?.showTitle && title && ( -

{title}

- )} - {preset?.showDescription && description && ( -

{description}

- )} -
- )} -
- ); -}; - -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 { + 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 = ({ + 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 ( +
+ +
+ +
+ + {showContent && ( +
+ {/* Author + Actions row */} +
+ {(showAuthor ?? preset?.showAuthor) !== false && ( + + )} + {(showActions ?? preset?.showActions) !== false && ( +
+ + +
+ )} +
+ + {/* Title & Description */} +
+ {(showTitle ?? preset?.showTitle) !== false && ( +
{title}
+ )} + {(showDescription ?? preset?.showDescription) !== false && description && ( +
+ +
+ )} +
+
+ )} +
+ ); + } + + // Grid Variant + return ( +
+
+ + + {/* TESTING: Entire hover overlay disabled */} + {false && showContent && ( +
+
+
+ {preset?.showAuthor !== false && ( + + )} + + {preset?.showActions !== false && ( +
+ +
+ )} +
+
+
+ )} +
+ {/* Info bar below image (preset or explicit props) */} + {(gridTitleOn || gridDescriptionOn) && (title || description) && ( +
+ {gridTitleOn && title && ( +

{title}

+ )} + {gridDescriptionOn && description && ( +

{description}

+ )} +
+ )} +
+ ); +}; + +export default PageCard; diff --git a/packages/ui/src/modules/posts/PostPage.tsx b/packages/ui/src/modules/posts/PostPage.tsx index c178e356..ee6d0af4 100644 --- a/packages/ui/src/modules/posts/PostPage.tsx +++ b/packages/ui/src/modules/posts/PostPage.tsx @@ -1,1101 +1,1107 @@ -import { useState, useEffect, useRef, Suspense, lazy } from "react"; -import { useParams, useNavigate, useSearchParams } from "react-router-dom"; -import { useAuth } from "@/hooks/useAuth"; -import { X } from 'lucide-react'; -import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; - -import { useWizardContext } from "@/hooks/useWizardContext"; -import { T, translate } from "@/i18n"; -import { isVideoType } from "@/lib/mediaRegistry"; - -import { getYouTubeId, getTikTokId, updateMediaPositions, getVideoUrlWithResolution } from "./views/utils"; - -import { YouTubeDialog } from "./views/components/YouTubeDialog"; -import { TikTokDialog } from "./views/components/TikTokDialog"; - -import UserPage from "@/modules/pages/UserPage"; -import { ThumbsRenderer } from "./views/renderers/ThumbsRenderer"; -import { CompactRenderer } from "./views/renderers/CompactRenderer"; -import { usePostActions } from "./views/usePostActions"; -import { exportMarkdown, downloadMediaItem } from "./views/PostActions"; -import { DeleteDialog } from "./views/components/DeleteDialogs"; -import { CategoryManager } from "@/components/widgets/CategoryManager"; -import { SEO } from "@/components/SEO"; - - -import '@vidstack/react/player/styles/default/theme.css'; -import '@vidstack/react/player/styles/default/layouts/video.css'; - -// New Modules -import { PostMediaItem as MediaItem, PostItem, UserProfile } from "./views/types"; -import { ImageFile, MediaType } from "@/types"; -import { uploadInternalVideo } from "@/utils/uploadUtils"; -import { fetchPageById } from "@/modules/pages/client-pages"; -import { addCollectionPictures, createPicture, fetchPictureById, fetchVersions, toggleLike, unlinkPictures, updatePicture, updateStorageFile, uploadFileToStorage, upsertPictures } from "@/modules/posts/client-pictures"; -import { fetchPostById, updatePostDetails } from "@/modules/posts/client-posts"; - - -// Heavy Components - Lazy Loaded -const ImagePickerDialog = lazy(() => import("@/components/widgets/ImagePickerDialog").then(module => ({ default: module.ImagePickerDialog }))); -const ImageWizard = lazy(() => import("@/components/ImageWizard")); -const EditImageModal = lazy(() => import("@/components/EditImageModal")); -const EditVideoModal = lazy(() => import("@/components/EditVideoModal")); -const SmartLightbox = lazy(() => import("./views/components/SmartLightbox")); - -interface PostProps { - postId?: string; - embedded?: boolean; - className?: string; -} - -const Post = ({ postId: propPostId, embedded = false, className }: PostProps) => { - const { id: paramId } = useParams<{ id: string }>(); - const id = propPostId || paramId; - const [searchParams, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); - - const { user } = useAuth(); - - const { setWizardImage } = useWizardContext(); - - // ... state ... - const [post, setPost] = useState(null); - const [mediaItems, setMediaItems] = useState([]); - const [mediaItem, setMediaItem] = useState(null); // Current displaying item - - // ... other state ... - const [isLiked, setIsLiked] = useState(false); - const [likesCount, setLikesCount] = useState(0); - const [loading, setLoading] = useState(true); - const [showEditModal, setShowEditModal] = useState(false); - const [lastTap, setLastTap] = useState(0); - const [showLightbox, setShowLightbox] = useState(false); - const preLightboxFocusRef = useRef(null); - const [isPublishing, setIsPublishing] = useState(false); - const [versionImages, setVersionImages] = useState([]); - // Don't calculate currentImageIndex here yet, wait for render or use memo - const [authorProfile, setAuthorProfile] = useState(null); - - const [youTubeUrl, setYouTubeUrl] = useState(''); - const [tikTokUrl, setTikTokUrl] = useState(''); - - // NOTE: llm hook removed from here, now inside SmartLightbox - - const isVideo = isVideoType(mediaItem?.type as MediaType); - - // Initialize viewMode from URL parameter - const [viewMode, setViewMode] = useState<'compact' | 'thumbs'>(() => { - const viewParam = searchParams.get('view'); - if (viewParam === 'compact' || viewParam === 'thumbs') { - return viewParam; - } - return 'compact'; - }); - - - - // Render Page Content if it's an internal page - - - // Calculate index safely - const currentImageIndex = mediaItems.findIndex(item => item.id === mediaItem?.id); - - // Unified URL sync: write `view`, `pic`, and `fullscreen` params atomically (standalone only) - const isInitialMount = useRef(true); - useEffect(() => { - if (embedded) return; - // Skip the very first render to avoid overwriting URL before data loads - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - const newParams = new URLSearchParams(searchParams); - newParams.set('view', viewMode); - - if (mediaItem?.id) { - newParams.set('pic', mediaItem.id); - } else { - newParams.delete('pic'); - } - - if (showLightbox) { - newParams.set('fullscreen', '1'); - } else { - newParams.delete('fullscreen'); - } - - // Only update if something actually changed - const currentView = searchParams.get('view'); - const currentPic = searchParams.get('pic'); - const currentFs = searchParams.get('fullscreen'); - const targetFs = showLightbox ? '1' : null; - if (currentView !== viewMode || currentPic !== (mediaItem?.id || null) || currentFs !== targetFs) { - setSearchParams(newParams, { replace: true }); - } - }, [viewMode, mediaItem?.id, showLightbox, embedded]); - - // Sync URL → state (only for external URL changes, e.g. browser back/forward) - useEffect(() => { - const viewParam = searchParams.get('view'); - if (viewParam === 'compact' || viewParam === 'thumbs') { - setViewMode(viewParam as any); - } - - // Restore pic selection from URL if items are loaded - const picParam = searchParams.get('pic'); - if (picParam && mediaItems.length > 0) { - const targetItem = mediaItems.find(item => item.id === picParam); - if (targetItem && targetItem.id !== mediaItem?.id) { - setMediaItem(targetItem); - setLikesCount(targetItem.likes_count || 0); - } - } - - // Restore fullscreen/lightbox state - const fsParam = searchParams.get('fullscreen'); - setShowLightbox(fsParam === '1'); - }, [searchParams]); - - const [removedItemIds, setRemovedItemIds] = useState>(new Set()); - - // Inline Editor State - const [isEditMode, setIsEditMode] = useState(false); - const [localPost, setLocalPost] = useState<{ title: string; description: string; settings?: any } | null>(null); - const [localMediaItems, setLocalMediaItems] = useState([]); - const [showGalleryPicker, setShowGalleryPicker] = useState(false); - const [showAIWizard, setShowAIWizard] = useState(false); - const insertIndexRef = useRef(0); - - const isOwner = user?.id === mediaItem?.user_id; - - const videoPosterUrl = (isVideo && mediaItem?.thumbnail_url) - ? (mediaItem.image_url.includes('/api/videos/') - ? mediaItem.thumbnail_url - : `${mediaItem.thumbnail_url}?width=1280&height=720&fit_mode=preserve&time=0`) - : undefined; - - const videoPlaybackUrl = (isVideo && mediaItem?.image_url) ? getVideoUrlWithResolution(mediaItem.image_url) : undefined; - - useEffect(() => { - const savedMode = localStorage.getItem('postViewMode'); - if (savedMode === 'compact' || savedMode === 'thumbs') { - setViewMode(savedMode as any); - } else if (post?.settings?.display && (post.settings.display === 'compact' || post.settings.display === 'thumbs')) { - setViewMode(post.settings.display); - } - }, [post]); - - const handleViewMode = (mode: 'compact' | 'thumbs') => { - setViewMode(mode); - // LocalStorage backup removed to favor URL matching - // localStorage.setItem('postViewMode', mode); - }; - - const handleRemoveFromPost = (index: number) => { - const itemToRemove = localMediaItems[index]; - if (!itemToRemove) return; - setRemovedItemIds(prev => new Set(prev).add(itemToRemove.id)); - const newItems = [...localMediaItems]; - newItems.splice(index, 1); - updateMediaPositions(newItems, setLocalMediaItems, setMediaItems); - toast.success(translate("Removed from post")); - }; - - const handleInlineUpload = async (files: File[], insertIndex: number) => { - if (!files.length || !user?.id) return; - toast.info(`Uploading ${files.length} images...`); - - const newItems = [...localMediaItems]; - const newUploads: any[] = []; - - for (const file of files) { - try { - if (file.type.startsWith('video')) { - // Handle video upload via internal API - const uploadData = await uploadInternalVideo(file, user.id); - - // Fetch the created picture record to get the correct URL and details - const picture = await fetchPictureById(uploadData.dbId); - if (!picture) throw new Error('Failed to retrieve uploaded video details'); - - const newItem = { - id: picture.id, - title: picture.title, - description: picture.description || '', - image_url: picture.image_url, - thumbnail_url: picture.thumbnail_url, - user_id: user.id, - post_id: post?.id, - type: picture.type || 'video', - created_at: picture.created_at, - position: 0, - meta: picture.meta - }; - newUploads.push(newItem); - - } else { - // Handle regular image upload to storage - const publicUrl = await uploadFileToStorage(user.id, file); - - const newItem = { - id: crypto.randomUUID(), - title: file.name.split('.')[0], - description: '', - image_url: publicUrl, - user_id: user.id, - post_id: post?.id, - type: 'image', - created_at: new Date().toISOString(), - position: 0 - }; - newUploads.push(newItem); - } - } catch (error) { - console.error('Error uploading file:', error); - toast.error(`Failed to upload ${file.name}`); - } - } - - if (newUploads.length > 0) { - newItems.splice(insertIndex, 0, ...newUploads); - const reordered = newItems.map((item, idx) => ({ ...item, position: idx })); - setLocalMediaItems(reordered); - toast.success(`Added ${newUploads.length} images`); - } - }; - - const openGalleryPicker = (index: number) => { - insertIndexRef.current = index; - setShowGalleryPicker(true); - }; - - const openAIWizard = (index: number) => { - insertIndexRef.current = index; - setWizardImage(null); - setShowAIWizard(true); - }; - - const handleAIWizardPublish = async (newImages: ImageFile[]) => { - if (!newImages?.length) return; - const newItems = newImages.map(img => ({ - id: crypto.randomUUID(), - title: img.title || 'AI Generated', - description: (img as any).aiText || '', - image_url: img.src, - thumbnail_url: img.src, - user_id: user?.id, - post_id: post?.id, - type: 'image', - created_at: new Date().toISOString(), - position: 0 - })); - const currentItems = [...localMediaItems]; - currentItems.splice(insertIndexRef.current, 0, ...newItems); - const reordered = currentItems.map((item, idx) => ({ ...item, position: idx })); - setLocalMediaItems(reordered); - setShowAIWizard(false); - toast.success(`Added ${newItems.length} AI generated image(s)`); - }; - - const handleGallerySelect = async (pictureId: string) => { - setShowGalleryPicker(false); - toast.info("Adding image from gallery..."); - try { - const picture = await fetchPictureById(pictureId); - if (!picture) return; - - const newItem = { - id: crypto.randomUUID(), - title: picture.title, - description: picture.description || '', - image_url: picture.image_url, - thumbnail_url: picture.thumbnail_url, - user_id: user?.id, - post_id: post?.id, - type: picture.type || 'image', - created_at: new Date().toISOString(), - position: 0, - meta: picture.meta - }; - const newItems = [...localMediaItems]; - newItems.splice(insertIndexRef.current, 0, newItem); - const reordered = newItems.map((item, idx) => ({ ...item, position: idx })); - setLocalMediaItems(reordered); - toast.success("Image added from gallery"); - } catch (error) { - console.error("Error adding from gallery:", error); - toast.error("Failed to add image"); - } - }; - - const toggleEditMode = () => { - if (!isEditMode) { - setLocalPost({ - title: post?.title || mediaItem?.title || '', - description: post?.description || mediaItem?.description || '', - settings: post?.settings || {}, - }); - const itemsWithPos = mediaItems.map((item, idx) => ({ - ...item, - position: item.position ?? idx - })).sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); - setLocalMediaItems(itemsWithPos); - } - setIsEditMode(!isEditMode); - }; - - const handleSaveChanges = async () => { - if (!localPost || !post) return; - toast.promise( - async () => { - // Only update the post record if it's a real post (not a pseudo post from a standalone picture) - if (!post.isPseudo) { - await updatePostDetails(post.id, { - title: localPost.title, - description: localPost.description, - settings: localPost.settings, - }); - } - - if (removedItemIds.size > 0) { - await unlinkPictures(Array.from(removedItemIds)); - } - - if (post.isPseudo && localMediaItems.length > 0) { - // For pseudo-posts (standalone pictures), update the picture directly - // Don't set post_id since there is no real post - const item = localMediaItems[0]; - await updatePicture(item.id, { - title: localPost.title || item.title, - description: localPost.description || item.description, - updated_at: new Date().toISOString(), - }); - } else { - const updates = localMediaItems.map((item, index) => ({ - id: item.id, - title: item.title, - description: item.description, - position: index, - updated_at: new Date().toISOString(), - user_id: user?.id || item.user_id, - post_id: post.id, - image_url: item.image_url, - type: item.type || 'image', - thumbnail_url: item.thumbnail_url - })); - - await upsertPictures(updates); - } - - setRemovedItemIds(new Set()); - setTimeout(() => window.location.reload(), 500); - }, - { - loading: 'Saving changes...', - success: 'Changes saved successfully!', - error: 'Failed to save changes', - } - ); - }; - - - const moveItem = (index: number, direction: 'up' | 'down') => { - const newItems = [...localMediaItems]; - if (direction === 'up' && index > 0) { - [newItems[index], newItems[index - 1]] = [newItems[index - 1], newItems[index]]; - } else if (direction === 'down' && index < newItems.length - 1) { - [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]]; - } - const reordered = newItems.map((item, idx) => ({ ...item, position: idx })); - setLocalMediaItems(reordered); - }; - - const handlePrevImage = () => { - if (currentImageIndex > 0) { - const newIndex = currentImageIndex - 1; - setMediaItem(mediaItems[newIndex]); - setLikesCount(mediaItems[newIndex].likes_count || 0); - } - }; - - const handleNextImage = () => { - if (currentImageIndex < mediaItems.length - 1) { - const newIndex = currentImageIndex + 1; - setMediaItem(mediaItems[newIndex]); - setLikesCount(mediaItems[newIndex].likes_count || 0); - } - }; - - - - const loadVersions = async () => { - if (!mediaItem || isVideo) return; - try { - const allImages = await fetchVersions(mediaItem, user?.id) as any[]; - const parentImage = allImages.find(img => !img.parent_id) || mediaItem; - const imageFiles: ImageFile[] = allImages.map(img => ({ - path: img.id, - src: img.image_url, - selected: img.id === mediaItem.id, - isGenerated: !!img.parent_id, - title: img.title || parentImage.title, - description: img.description || parentImage.description - })); - setVersionImages(imageFiles); - } catch (error) { - console.error('Error loading versions:', error); - } - }; - - - useEffect(() => { - if (id) { - fetchMedia(); - } - }, [id, user?.id]); - - useEffect(() => { - if (mediaItem) { - // loadVersions(); // Deprecated: Versions handled by server aggregation - // fetchAuthorProfile(); // Deprecated: Author returned in post details - // checkIfLiked(mediaItem.id); // Deprecated: is_liked returned in post details - - // We still update local like state when mediaItem changes - if (mediaItem.is_liked !== undefined) { - setIsLiked(mediaItem.is_liked || false); - } - if (mediaItem.likes_count !== undefined) { - setLikesCount(mediaItem.likes_count); - } - } - }, [mediaItem]); - - useEffect(() => { - window.scrollTo({ top: 0, behavior: 'instant' }); - }, []); - - useEffect(() => { - const handleKeyPress = (e: KeyboardEvent) => { - const activeElement = document.activeElement; - const isInputFocused = activeElement && ( - activeElement.tagName === 'INPUT' || - activeElement.tagName === 'TEXTAREA' || - activeElement.getAttribute('contenteditable') === 'true' || - activeElement.getAttribute('role') === 'textbox' - ); - if (isInputFocused) return; - - if (e.key === 'ArrowLeft') { - e.preventDefault(); - if (currentImageIndex > 0) handlePrevImage(); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - if (currentImageIndex < mediaItems.length - 1) handleNextImage(); - } else if ((e.key === ' ' || e.key === 'Enter') && !showLightbox) { - e.preventDefault(); - preLightboxFocusRef.current = document.activeElement as HTMLElement; - setShowLightbox(true); - } - }; - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, [showLightbox, currentImageIndex, mediaItems]); - - - const fetchMedia = async () => { - // Versions and likes are resolved server-side now - - try { - const postData = await fetchPostById(id!); - if (postData) { - let items = (postData.pictures as any[]).map((p: any) => ({ - ...p, - type: p.type as MediaType, - renderKey: p.id - })).sort((a, b) => (a.position || 0) - (b.position || 0)); - - // Server now returns full version set and like status - // items already contains all versions and is_liked from fetchPostById API response - - setPost({ ...postData, pictures: items }); - if (items.length === 0 && (postData.settings as any)?.link) { - // Create virtual picture for Link Post - const settings = (postData.settings as any); - items.push({ - id: postData.id, - title: postData.title, - description: postData.description, - image_url: settings.image_url || `https://picsum.photos/seed/800/600`, // Fallback - thumbnail_url: settings.thumbnail_url || null, - user_id: postData.user_id, - type: 'page-external', - created_at: postData.created_at, - position: 0, - renderKey: postData.id, - meta: { url: settings.link }, - likes_count: 0, // Could fetch real likes on post container if supported - visible: true - }); - } - - if (items.length > 0) { - setMediaItems(items); - // Restore selection from URL ?pic= param, then prefer is_selected, fallback to first - const picParam = searchParams.get('pic'); - const urlItem = picParam ? items.find((i: any) => i.id === picParam) : null; - const selectedItems = items.filter((i: any) => i.is_selected); - const initialItem = urlItem || selectedItems[0] || items[0]; - setMediaItem(initialItem); - setLikesCount(initialItem.likes_count || 0); - } else { - toast.error('This post has no media'); - } - return; - } - - const pictureData = await fetchPictureById(id!); - if (pictureData) { - if (pictureData.post_id) { - const fullPostData = await fetchPostById(pictureData.post_id); - if (fullPostData) { - let items = (fullPostData.pictures as any[]).map((p: any) => ({ - ...p, - type: p.type as MediaType, - renderKey: p.id - })).sort((a, b) => (a.position || 0) - (b.position || 0)); - - // Versions resolved server-side; fallback path might miss them if not updated to use API - // items = await resolveVersions(items); - items = items.filter((item: any) => item.visible || user?.id === item.user_id); - // Re-sort after filtering to ensure position order - items.sort((a: any, b: any) => (a.position || 0) - (b.position || 0)); - - setPost({ ...fullPostData, settings: fullPostData.settings as any, pictures: items }); - setMediaItems(items); - - // Check if requested ID is in the resolved list - const initialIndex = items.findIndex((p: any) => p.id === id); - - if (initialIndex >= 0) { - setMediaItem(items[initialIndex]); - setLikesCount(items[initialIndex].likes_count || 0); - } else { - // Requested ID might have been swapped out. - // Try to find if it was part of a family that is now represented by a selected version - const rootId = pictureData.parent_id || pictureData.id; - const swappedIndex = items.findIndex((p: any) => (p.parent_id || p.id) === rootId); - - if (swappedIndex >= 0) { - setMediaItem(items[swappedIndex]); - setLikesCount(items[swappedIndex].likes_count || 0); - } else { - const fallbackSelected = items.filter((i: any) => i.is_selected); - const fallbackItem = fallbackSelected[0] || items[0]; - setMediaItem(fallbackItem); - setLikesCount(fallbackItem.likes_count || 0); - } - } - } - return; - } - - const pseudoPost: PostItem = { - id: pictureData.post_id || pictureData.id, - title: pictureData.title || 'Untitled', - description: pictureData.description, - user_id: pictureData.user_id, - created_at: pictureData.created_at, - updated_at: pictureData.created_at, - pictures: [{ ...pictureData, type: pictureData.type as MediaType, mediaType: pictureData.type }], - isPseudo: true - }; - setPost(pseudoPost); - setMediaItems(pseudoPost.pictures!); - setMediaItem(pseudoPost.pictures![0]); - setLikesCount(pictureData.likes_count || 0); - return; - } - - // 3. Try fetching as a Page (for page-intern items) - try { - const pageData = await fetchPageById(id!); - if (pageData) { - const pseudoPost: PostItem = { - id: pageData.id, - title: pageData.title, - description: null, - user_id: pageData.owner, - created_at: pageData.created_at, - updated_at: pageData.created_at, - pictures: [], - isPseudo: true, - type: 'page-intern', - meta: { slug: pageData.slug } - }; - setPost(pseudoPost); - setLoading(false); - return; - } - } catch (e) { - console.error("Error fetching page:", e); - } - - toast.error(translate('Content not found')); - navigate('/'); - } catch (error) { - console.error('Error fetching content:', error); - toast.error(translate('Failed to load content')); - navigate('/'); - } finally { - setLoading(false); - } - }; - - const actions = usePostActions({ - post, - mediaItems, - setMediaItems, - mediaItem, - user, - fetchMedia - }); - - const handleYouTubeAdd = async () => { - const videoId = getYouTubeId(youTubeUrl); - if (!videoId) { - toast.error(translate("Invalid YouTube URL")); - return; - } - const embedUrl = `https://www.youtube.com/embed/${videoId}`; - const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; - const newVideoItem: any = { - id: crypto.randomUUID(), - type: 'youtube', - image_url: embedUrl, - thumbnail_url: thumbnailUrl, - title: 'YouTube Video', - description: '', - user_id: user?.id || '', - created_at: new Date().toISOString(), - likes_count: 0 - }; - if (insertIndexRef.current !== -1) { - const newItems = [...localMediaItems]; - newItems.splice(insertIndexRef.current, 0, newVideoItem); - updateMediaPositions(newItems, setLocalMediaItems, setMediaItems); - } else { - updateMediaPositions([...localMediaItems, newVideoItem], setLocalMediaItems, setMediaItems); - } - setYouTubeUrl(''); - actions.setShowYouTubeDialog(false); - toast.success(translate("YouTube video added")); - }; - - const handleTikTokAdd = async () => { - const videoId = getTikTokId(tikTokUrl); - if (!videoId) { - toast.error(translate("Invalid TikTok URL")); - return; - } - const embedUrl = `https://www.tiktok.com/embed/v2/${videoId}`; - const thumbnailUrl = `https://sf16-scmcdn-sg.ibytedtos.com/goofy/tiktok/web/node/_next/static/images/logo-dark-e95da587b6efa1520dcd11f4b45c0cf6.svg`; - const newVideoItem: any = { - id: crypto.randomUUID(), - type: 'tiktok', - image_url: embedUrl, - thumbnail_url: thumbnailUrl, - title: 'TikTok Video', - description: '', - user_id: user?.id || '', - created_at: new Date().toISOString(), - likes_count: 0 - }; - if (insertIndexRef.current !== -1) { - const newItems = [...localMediaItems]; - newItems.splice(insertIndexRef.current, 0, newVideoItem); - updateMediaPositions(newItems, setLocalMediaItems, setMediaItems); - } else { - updateMediaPositions([...localMediaItems, newVideoItem], setLocalMediaItems, setMediaItems); - } - setTikTokUrl(''); - actions.setShowTikTokDialog(false); - toast.success(translate("TikTok video added")); - }; - - - const handleLike = async () => { - if (!user || !mediaItem) { - toast.error(translate('Please sign in to like this')); - return; - } - try { - const isNowLiked = await toggleLike(user.id, mediaItem.id, isLiked); - setIsLiked(isNowLiked); - setLikesCount(prev => isNowLiked ? prev + 1 : prev - 1); - - setMediaItems(prevItems => prevItems.map(item => { - if (item.id === mediaItem.id) { - return { - ...item, - is_liked: isNowLiked, - likes_count: (item.likes_count || 0) + (isNowLiked ? 1 : -1) - }; - } - return item; - })); - } catch (error) { - console.error('Error toggling like:', error); - toast.error(translate('Failed to update like')); - } - }; - - const handleDownload = async () => { - await downloadMediaItem(mediaItem, isVideo); - }; - - const handlePublish = async (option: 'overwrite' | 'new' | 'version', imageUrl: string, newTitle: string, description?: string, parentId?: string, collectionIds?: string[]) => { - if (!mediaItem || isVideo || !user) { - toast.error(translate('Please sign in to publish images')); - return; - } - setIsPublishing(true); - try { - const response = await fetch(imageUrl); - const blob = await response.blob(); - - if (option === 'overwrite') { - const currentImageUrl = mediaItem.image_url; - if (currentImageUrl.includes('supabase.co/storage/')) { - const urlParts = currentImageUrl.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const bucketPath = `${mediaItem.user_id}/${fileName}`; - await updateStorageFile(bucketPath, blob); - toast.success(translate('Image updated successfully!')); - fetchMedia(); - } else { - toast.error(translate('Cannot overwrite this image')); - return; - } - } else if (option === 'version') { - const publicUrl = await uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-version.png`); - const pictureData = await createPicture({ - title: newTitle?.trim() || null, - description: description || `Generated from: ${mediaItem.title}`, - image_url: publicUrl, - user_id: user.id, - parent_id: parentId || mediaItem.id, - is_selected: false, - visible: false - }); - if (collectionIds && collectionIds.length > 0 && pictureData) { - await addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id }))); - } - toast.success(translate('Version saved successfully!')); - loadVersions(); - } else { - const publicUrl = await uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-generated.png`); - const pictureData = await createPicture({ - title: newTitle?.trim() || null, - description: description || `Generated from: ${mediaItem.title}`, - image_url: publicUrl, - user_id: user.id - }); - if (collectionIds && collectionIds.length > 0 && pictureData) { - await addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id }))); - } - toast.success(translate('Image published to gallery!')); - } - setShowLightbox(false); - // llm state cleared by component - - } catch (error) { - console.error('Error publishing image:', error); - toast.error(translate('Failed to publish image')); - } finally { - setIsPublishing(false); - } - }; - - const handleOpenInWizard = (imageUrl?: string) => { - if (!mediaItem || isVideo) return; - const imageToEdit = imageUrl || mediaItem.image_url; - const imageData = { - id: mediaItem.id, - src: imageToEdit, - title: mediaItem.title, - realDatabaseId: mediaItem.id, - selected: true - }; - setWizardImage(imageData, window.location.pathname); - setShowLightbox(false); - navigate('/wizard'); - }; - - const handleEditPicture = () => { - if (!mediaItem) return; - setShowEditModal(true); - }; - - const handleEditPost = () => { - if (!post) return; - navigate(`/post/${post.id}/edit`); - }; - - const rendererProps = { - post, authorProfile, mediaItems, localMediaItems, mediaItem: mediaItem!, - user, isOwner: !!isOwner, isEditMode, isLiked, likesCount, - localPost, setLocalPost, setLocalMediaItems, - - onEditModeToggle: toggleEditMode, - onEditPost: handleEditPost, - onViewModeChange: handleViewMode, - onExportMarkdown: () => exportMarkdown(post, mediaItem!, mediaItems, authorProfile), - onSaveChanges: handleSaveChanges, - onDeletePost: () => actions.setShowDeletePostDialog(true), - onDeletePicture: () => actions.setShowDeletePictureDialog(true), - onLike: handleLike, - onUnlinkImage: actions.handleUnlinkImage, - onRemoveFromPost: handleRemoveFromPost, - onEditPicture: handleEditPicture, - onGalleryPickerOpen: openGalleryPicker, - onYouTubeAdd: () => actions.setShowYouTubeDialog(true), - onTikTokAdd: () => actions.setShowTikTokDialog(true), - onAIWizardOpen: openAIWizard, - onInlineUpload: handleInlineUpload, - onMoveItem: moveItem, - onMediaSelect: setMediaItem, - onExpand: (item: MediaItem) => { setMediaItem(item); setShowLightbox(true); }, - onDownload: handleDownload, - onCategoryManagerOpen: () => actions.setShowCategoryManager(true), - - - currentImageIndex, - videoPlaybackUrl, - videoPosterUrl, - versionImages, - - handlePrevImage, - embedded - }; - - // Render Page Content if it's an internal page - if (post?.type === 'page-intern' && post.meta?.slug) { - return ( - - ); - } - - if (loading) { - return ( -
-
Loading...
-
- ); - } - - if (!mediaItem) { - return ( -
-
-
Content not found
- {!embedded && } -
-
- ); - } - - const containerClassName = embedded - ? `flex flex-col bg-background h-[inherit] ${className || ''}` - : "bg-background flex flex-col"; - - if (!embedded && !className) { - className = "h-[calc(100vh-4rem)]" - } - /* - console.log('containerClassName', containerClassName); - console.log('className', className); - console.log('embedded', embedded); -*/ - - return ( -
- {post && ( - - )} -
- - {viewMode === 'thumbs' ? ( - - ) : ( - - )} -
- - Loading editor...
}> - {showEditModal && !isVideo && ( - { - setShowEditModal(false); - fetchMedia(); - }} - /> - )} - - {showEditModal && isVideo && ( - { - setShowEditModal(false); - fetchMedia(); - }} - /> - )} - - - { - !isVideo && showLightbox && ( - Loading Lightbox...}> - { - setShowLightbox(false); - // Restore focus to the element that was focused before lightbox opened - requestAnimationFrame(() => { - //preLightboxFocusRef.current?.focus(); - //preLightboxFocusRef.current = null; - }); - }} - mediaItem={mediaItem} - user={user} - isVideo={false} - onPublish={handlePublish} - onNavigate={(direction) => { - if (direction === 'next') handleNextImage(); - else handlePrevImage(); - }} - onOpenInWizard={() => handleOpenInWizard()} // SmartLightbox handles the argument if needed, or we adapt - currentIndex={currentImageIndex} - totalCount={mediaItems.length} - /> - - ) - } - - {/* Dialogs */} - - - - - - - - - - {showEditModal && mediaItem && ( - { - fetchMedia(); - setShowEditModal(false); - }} - /> - )} - - setShowGalleryPicker(false)} - onSelect={handleGallerySelect} - /> - - {showAIWizard && ( -
-
- - setShowAIWizard(false)} - mode="default" - initialPostTitle={post?.title || ""} - initialPostDescription={post?.description || ""} - onPublish={handleAIWizardPublish as any} - /> -
-
- )} - - actions.setShowCategoryManager(false)} - currentPageId={post?.id} - currentPageMeta={post?.meta} - onPageMetaUpdate={actions.handleMetaUpdate} - filterByType="pages" - defaultMetaType="pages" - /> -
- - ); -}; - -export default Post; +import { useState, useEffect, useRef, Suspense, lazy } from "react"; +import { useParams, useNavigate, useSearchParams } from "react-router-dom"; +import { useAuth } from "@/hooks/useAuth"; +import { X } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +import { useWizardContext } from "@/hooks/useWizardContext"; +import { T, translate } from "@/i18n"; +import { isVideoType } from "@/lib/mediaRegistry"; + +import { getYouTubeId, getTikTokId, updateMediaPositions, getVideoUrlWithResolution } from "./views/utils"; + +import { YouTubeDialog } from "./views/components/YouTubeDialog"; +import { TikTokDialog } from "./views/components/TikTokDialog"; + +import UserPage from "@/modules/pages/UserPage"; +import { ThumbsRenderer } from "./views/renderers/ThumbsRenderer"; +import { CompactRenderer } from "./views/renderers/CompactRenderer"; +import { usePostActions } from "./views/usePostActions"; +import { exportMarkdown, downloadMediaItem } from "./views/PostActions"; +import { DeleteDialog } from "./views/components/DeleteDialogs"; +import { CategoryManager } from "@/components/widgets/CategoryManager"; +import { SEO } from "@/components/SEO"; + + +import '@vidstack/react/player/styles/default/theme.css'; +import '@vidstack/react/player/styles/default/layouts/video.css'; + +// New Modules +import { PostMediaItem as MediaItem, PostItem, UserProfile } from "./views/types"; +import { ImageFile, MediaType } from "@/types"; +import { uploadInternalVideo } from "@/utils/uploadUtils"; +import { fetchPageById } from "@/modules/pages/client-pages"; +import { addCollectionPictures, createPicture, fetchPictureById, fetchVersions, toggleLike, unlinkPictures, updatePicture, updateStorageFile, uploadFileToStorage, upsertPictures } from "@/modules/posts/client-pictures"; +import { fetchPostById, updatePostDetails } from "@/modules/posts/client-posts"; + + +// Heavy Components - Lazy Loaded +const ImagePickerDialog = lazy(() => import("@/components/widgets/ImagePickerDialog").then(module => ({ default: module.ImagePickerDialog }))); +const ImageWizard = lazy(() => import("@/components/ImageWizard")); +const EditImageModal = lazy(() => import("@/components/EditImageModal")); +const EditVideoModal = lazy(() => import("@/components/EditVideoModal")); +const SmartLightbox = lazy(() => import("./views/components/SmartLightbox")); + +interface PostProps { + postId?: string; + embedded?: boolean; + className?: string; +} + +const Post = ({ postId: propPostId, embedded = false, className }: PostProps) => { + const { id: paramId } = useParams<{ id: string }>(); + const id = propPostId || paramId; + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const { user } = useAuth(); + + const { setWizardImage } = useWizardContext(); + + // ... state ... + const [post, setPost] = useState(null); + const [mediaItems, setMediaItems] = useState([]); + const [mediaItem, setMediaItem] = useState(null); // Current displaying item + + // ... other state ... + const [isLiked, setIsLiked] = useState(false); + const [likesCount, setLikesCount] = useState(0); + const [loading, setLoading] = useState(true); + const [showEditModal, setShowEditModal] = useState(false); + const [lastTap, setLastTap] = useState(0); + const [showLightbox, setShowLightbox] = useState(false); + const preLightboxFocusRef = useRef(null); + const [isPublishing, setIsPublishing] = useState(false); + const [versionImages, setVersionImages] = useState([]); + // Don't calculate currentImageIndex here yet, wait for render or use memo + const [authorProfile, setAuthorProfile] = useState(null); + + const [youTubeUrl, setYouTubeUrl] = useState(''); + const [tikTokUrl, setTikTokUrl] = useState(''); + + // NOTE: llm hook removed from here, now inside SmartLightbox + + const isVideo = isVideoType(mediaItem?.type as MediaType); + + // Initialize viewMode from URL parameter + const [viewMode, setViewMode] = useState<'compact' | 'thumbs'>(() => { + const viewParam = searchParams.get('view'); + if (viewParam === 'compact' || viewParam === 'thumbs') { + return viewParam; + } + return 'compact'; + }); + + + + // Render Page Content if it's an internal page + + + // Calculate index safely + const currentImageIndex = mediaItems.findIndex(item => item.id === mediaItem?.id); + + // Unified URL sync: write `view`, `pic`, and `fullscreen` params atomically (standalone only) + const isInitialMount = useRef(true); + useEffect(() => { + if (embedded) return; + // Skip the very first render to avoid overwriting URL before data loads + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + const newParams = new URLSearchParams(searchParams); + newParams.set('view', viewMode); + + if (mediaItem?.id) { + newParams.set('pic', mediaItem.id); + } else { + newParams.delete('pic'); + } + + if (showLightbox) { + newParams.set('fullscreen', '1'); + } else { + newParams.delete('fullscreen'); + } + + // Only update if something actually changed + const currentView = searchParams.get('view'); + const currentPic = searchParams.get('pic'); + const currentFs = searchParams.get('fullscreen'); + const targetFs = showLightbox ? '1' : null; + if (currentView !== viewMode || currentPic !== (mediaItem?.id || null) || currentFs !== targetFs) { + setSearchParams(newParams, { replace: true }); + } + }, [viewMode, mediaItem?.id, showLightbox, embedded]); + + // Sync URL → state (only for external URL changes, e.g. browser back/forward) + useEffect(() => { + const viewParam = searchParams.get('view'); + if (viewParam === 'compact' || viewParam === 'thumbs') { + setViewMode(viewParam as any); + } + + // Restore pic selection from URL if items are loaded + const picParam = searchParams.get('pic'); + if (picParam && mediaItems.length > 0) { + const targetItem = mediaItems.find(item => item.id === picParam); + if (targetItem && targetItem.id !== mediaItem?.id) { + setMediaItem(targetItem); + setLikesCount(targetItem.likes_count || 0); + } + } + + // Restore fullscreen/lightbox state + const fsParam = searchParams.get('fullscreen'); + setShowLightbox(fsParam === '1'); + }, [searchParams]); + + const [removedItemIds, setRemovedItemIds] = useState>(new Set()); + + // Inline Editor State + const [isEditMode, setIsEditMode] = useState(false); + const [localPost, setLocalPost] = useState<{ title: string; description: string; settings?: any } | null>(null); + const [localMediaItems, setLocalMediaItems] = useState([]); + const [showGalleryPicker, setShowGalleryPicker] = useState(false); + const [showAIWizard, setShowAIWizard] = useState(false); + const insertIndexRef = useRef(0); + + const isOwner = user?.id === mediaItem?.user_id; + + const videoPosterUrl = (isVideo && mediaItem?.thumbnail_url) + ? (mediaItem.image_url.includes('/api/videos/') + ? mediaItem.thumbnail_url + : `${mediaItem.thumbnail_url}?width=1280&height=720&fit_mode=preserve&time=0`) + : undefined; + + const videoPlaybackUrl = (isVideo && mediaItem?.image_url) ? getVideoUrlWithResolution(mediaItem.image_url) : undefined; + + useEffect(() => { + const savedMode = localStorage.getItem('postViewMode'); + if (savedMode === 'compact' || savedMode === 'thumbs') { + setViewMode(savedMode as any); + } else if (post?.settings?.display && (post.settings.display === 'compact' || post.settings.display === 'thumbs')) { + setViewMode(post.settings.display); + } + }, [post]); + + const handleViewMode = (mode: 'compact' | 'thumbs') => { + setViewMode(mode); + // LocalStorage backup removed to favor URL matching + // localStorage.setItem('postViewMode', mode); + }; + + const handleRemoveFromPost = (index: number) => { + const itemToRemove = localMediaItems[index]; + if (!itemToRemove) return; + setRemovedItemIds(prev => new Set(prev).add(itemToRemove.id)); + const newItems = [...localMediaItems]; + newItems.splice(index, 1); + updateMediaPositions(newItems, setLocalMediaItems, setMediaItems); + toast.success(translate("Removed from post")); + }; + + const handleInlineUpload = async (files: File[], insertIndex: number) => { + if (!files.length || !user?.id) return; + toast.info(`Uploading ${files.length} images...`); + + const newItems = [...localMediaItems]; + const newUploads: any[] = []; + + for (const file of files) { + try { + if (file.type.startsWith('video')) { + // Handle video upload via internal API + const uploadData = await uploadInternalVideo(file, user.id); + + // Fetch the created picture record to get the correct URL and details + const picture = await fetchPictureById(uploadData.dbId); + if (!picture) throw new Error('Failed to retrieve uploaded video details'); + + const newItem = { + id: picture.id, + title: picture.title, + description: picture.description || '', + image_url: picture.image_url, + thumbnail_url: picture.thumbnail_url, + user_id: user.id, + post_id: post?.id, + type: picture.type || 'video', + created_at: picture.created_at, + position: 0, + meta: picture.meta + }; + newUploads.push(newItem); + + } else { + // Handle regular image upload to storage + const publicUrl = await uploadFileToStorage(user.id, file); + + const newItem = { + id: crypto.randomUUID(), + title: file.name.split('.')[0], + description: '', + image_url: publicUrl, + user_id: user.id, + post_id: post?.id, + type: 'image', + created_at: new Date().toISOString(), + position: 0 + }; + newUploads.push(newItem); + } + } catch (error) { + console.error('Error uploading file:', error); + toast.error(`Failed to upload ${file.name}`); + } + } + + if (newUploads.length > 0) { + newItems.splice(insertIndex, 0, ...newUploads); + const reordered = newItems.map((item, idx) => ({ ...item, position: idx })); + setLocalMediaItems(reordered); + toast.success(`Added ${newUploads.length} images`); + } + }; + + const openGalleryPicker = (index: number) => { + insertIndexRef.current = index; + setShowGalleryPicker(true); + }; + + const openAIWizard = (index: number) => { + insertIndexRef.current = index; + setWizardImage(null); + setShowAIWizard(true); + }; + + const handleAIWizardPublish = async (newImages: ImageFile[]) => { + if (!newImages?.length) return; + const newItems = newImages.map(img => ({ + id: crypto.randomUUID(), + title: img.title || 'AI Generated', + description: (img as any).aiText || '', + image_url: img.src, + thumbnail_url: img.src, + user_id: user?.id, + post_id: post?.id, + type: 'image', + created_at: new Date().toISOString(), + position: 0 + })); + const currentItems = [...localMediaItems]; + currentItems.splice(insertIndexRef.current, 0, ...newItems); + const reordered = currentItems.map((item, idx) => ({ ...item, position: idx })); + setLocalMediaItems(reordered); + setShowAIWizard(false); + toast.success(`Added ${newItems.length} AI generated image(s)`); + }; + + const handleGallerySelect = async (pictureId: string) => { + setShowGalleryPicker(false); + toast.info("Adding image from gallery..."); + try { + const picture = await fetchPictureById(pictureId); + if (!picture) return; + + const newItem = { + id: crypto.randomUUID(), + title: picture.title, + description: picture.description || '', + image_url: picture.image_url, + thumbnail_url: picture.thumbnail_url, + user_id: user?.id, + post_id: post?.id, + type: picture.type || 'image', + created_at: new Date().toISOString(), + position: 0, + meta: picture.meta + }; + const newItems = [...localMediaItems]; + newItems.splice(insertIndexRef.current, 0, newItem); + const reordered = newItems.map((item, idx) => ({ ...item, position: idx })); + setLocalMediaItems(reordered); + toast.success("Image added from gallery"); + } catch (error) { + console.error("Error adding from gallery:", error); + toast.error("Failed to add image"); + } + }; + + const toggleEditMode = () => { + if (!isEditMode) { + setLocalPost({ + title: post?.title || mediaItem?.title || '', + description: post?.description || mediaItem?.description || '', + settings: post?.settings || {}, + }); + const itemsWithPos = mediaItems.map((item, idx) => ({ + ...item, + position: item.position ?? idx + })).sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + setLocalMediaItems(itemsWithPos); + } + setIsEditMode(!isEditMode); + }; + + const handleSaveChanges = async () => { + if (!localPost || !post) return; + toast.promise( + async () => { + // Only update the post record if it's a real post (not a pseudo post from a standalone picture) + if (!post.isPseudo) { + await updatePostDetails(post.id, { + title: localPost.title, + description: localPost.description, + settings: localPost.settings, + }); + } + + if (removedItemIds.size > 0) { + await unlinkPictures(Array.from(removedItemIds)); + } + + if (post.isPseudo && localMediaItems.length > 0) { + // For pseudo-posts (standalone pictures), update the picture directly + // Don't set post_id since there is no real post + const item = localMediaItems[0]; + await updatePicture(item.id, { + title: localPost.title || item.title, + description: localPost.description || item.description, + updated_at: new Date().toISOString(), + }); + } else { + const updates = localMediaItems.map((item, index) => ({ + id: item.id, + title: item.title, + description: item.description, + position: index, + updated_at: new Date().toISOString(), + user_id: user?.id || item.user_id, + post_id: post.id, + image_url: item.image_url, + type: item.type || 'image', + thumbnail_url: item.thumbnail_url + })); + + await upsertPictures(updates); + } + + setRemovedItemIds(new Set()); + setTimeout(() => window.location.reload(), 500); + }, + { + loading: 'Saving changes...', + success: 'Changes saved successfully!', + error: 'Failed to save changes', + } + ); + }; + + + const moveItem = (index: number, direction: 'up' | 'down') => { + const newItems = [...localMediaItems]; + if (direction === 'up' && index > 0) { + [newItems[index], newItems[index - 1]] = [newItems[index - 1], newItems[index]]; + } else if (direction === 'down' && index < newItems.length - 1) { + [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]]; + } + const reordered = newItems.map((item, idx) => ({ ...item, position: idx })); + setLocalMediaItems(reordered); + }; + + const handlePrevImage = () => { + if (currentImageIndex > 0) { + const newIndex = currentImageIndex - 1; + setMediaItem(mediaItems[newIndex]); + setLikesCount(mediaItems[newIndex].likes_count || 0); + } + }; + + const handleNextImage = () => { + if (currentImageIndex < mediaItems.length - 1) { + const newIndex = currentImageIndex + 1; + setMediaItem(mediaItems[newIndex]); + setLikesCount(mediaItems[newIndex].likes_count || 0); + } + }; + + + + const loadVersions = async () => { + if (!mediaItem || isVideo) return; + try { + const allImages = await fetchVersions(mediaItem, user?.id) as any[]; + const parentImage = allImages.find(img => !img.parent_id) || mediaItem; + const imageFiles: ImageFile[] = allImages.map(img => ({ + path: img.id, + src: img.image_url, + selected: img.id === mediaItem.id, + isGenerated: !!img.parent_id, + title: img.title || parentImage.title, + description: img.description || parentImage.description + })); + setVersionImages(imageFiles); + } catch (error) { + console.error('Error loading versions:', error); + } + }; + + + useEffect(() => { + if (id) { + fetchMedia(); + } + }, [id, user?.id]); + + useEffect(() => { + if (mediaItem) { + // loadVersions(); // Deprecated: Versions handled by server aggregation + // fetchAuthorProfile(); // Deprecated: Author returned in post details + // checkIfLiked(mediaItem.id); // Deprecated: is_liked returned in post details + + // We still update local like state when mediaItem changes + if (mediaItem.is_liked !== undefined) { + setIsLiked(mediaItem.is_liked || false); + } + if (mediaItem.likes_count !== undefined) { + setLikesCount(mediaItem.likes_count); + } + } + }, [mediaItem]); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'instant' }); + }, []); + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + const activeElement = document.activeElement; + const isInputFocused = activeElement && ( + activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.getAttribute('contenteditable') === 'true' || + activeElement.getAttribute('role') === 'textbox' + ); + if (isInputFocused) return; + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (currentImageIndex > 0) handlePrevImage(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + if (currentImageIndex < mediaItems.length - 1) handleNextImage(); + } else if ((e.key === ' ' || e.key === 'Enter') && !showLightbox) { + e.preventDefault(); + preLightboxFocusRef.current = document.activeElement as HTMLElement; + setShowLightbox(true); + } + }; + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, [showLightbox, currentImageIndex, mediaItems]); + + + const fetchMedia = async () => { + // Versions and likes are resolved server-side now + + try { + const postData = await fetchPostById(id!); + if (postData) { + let items = (postData.pictures as any[]).map((p: any) => ({ + ...p, + type: p.type as MediaType, + renderKey: p.id + })).sort((a, b) => (a.position || 0) - (b.position || 0)); + + items = items.filter((item: any) => item.visible ); + items.sort((a: any, b: any) => (a.position || 0) - (b.position || 0)); + // Server now returns full version set and like status + // items already contains all versions and is_liked from fetchPostById API response + + setPost({ ...postData, pictures: items }); + if (items.length === 0 && (postData.settings as any)?.link) { + // Create virtual picture for Link Post + const settings = (postData.settings as any); + items.push({ + id: postData.id, + title: postData.title, + description: postData.description, + image_url: settings.image_url || `https://picsum.photos/seed/800/600`, // Fallback + thumbnail_url: settings.thumbnail_url || null, + user_id: postData.user_id, + type: 'page-external', + created_at: postData.created_at, + position: 0, + renderKey: postData.id, + meta: { url: settings.link }, + likes_count: 0, // Could fetch real likes on post container if supported + visible: true + }); + } + + if (items.length > 0) { + setMediaItems(items); + // Restore selection from URL ?pic= param, then prefer is_selected, fallback to first + const picParam = searchParams.get('pic'); + const urlItem = picParam ? items.find((i: any) => i.id === picParam) : null; + const selectedItems = items.filter((i: any) => i.is_selected); + const initialItem = urlItem || selectedItems[0] || items[0]; + setMediaItem(initialItem); + setLikesCount(initialItem.likes_count || 0); + } else { + toast.error('This post has no media'); + } + return; + } + + const pictureData = await fetchPictureById(id!); + if (pictureData) { + if (pictureData.post_id) { + const fullPostData = await fetchPostById(pictureData.post_id); + if (fullPostData) { + let items = (fullPostData.pictures as any[]).map((p: any) => ({ + ...p, + type: p.type as MediaType, + renderKey: p.id + })).sort((a, b) => (a.position || 0) - (b.position || 0)); + + // Versions resolved server-side; fallback path might miss them if not updated to use API + // items = await resolveVersions(items); + //items = items.filter((item: any) => item.visible || user?.id === item.user_id); + items = items.filter((item: any) => item.visible ); + // Re-sort after filtering to ensure position order + items.sort((a: any, b: any) => (a.position || 0) - (b.position || 0)); + + setPost({ ...fullPostData, settings: fullPostData.settings as any, pictures: items }); + + setMediaItems(items); + + // Check if requested ID is in the resolved list + const initialIndex = items.findIndex((p: any) => p.id === id); + + if (initialIndex >= 0) { + setMediaItem(items[initialIndex]); + setLikesCount(items[initialIndex].likes_count || 0); + } else { + // Requested ID might have been swapped out. + // Try to find if it was part of a family that is now represented by a selected version + const rootId = pictureData.parent_id || pictureData.id; + const swappedIndex = items.findIndex((p: any) => (p.parent_id || p.id) === rootId); + + if (swappedIndex >= 0) { + setMediaItem(items[swappedIndex]); + setLikesCount(items[swappedIndex].likes_count || 0); + } else { + const fallbackSelected = items.filter((i: any) => i.is_selected); + const fallbackItem = fallbackSelected[0] || items[0]; + setMediaItem(fallbackItem); + setLikesCount(fallbackItem.likes_count || 0); + } + } + } + return; + } + + const pseudoPost: PostItem = { + id: pictureData.post_id || pictureData.id, + title: pictureData.title || 'Untitled', + description: pictureData.description, + user_id: pictureData.user_id, + created_at: pictureData.created_at, + updated_at: pictureData.created_at, + pictures: [{ ...pictureData, type: pictureData.type as MediaType, mediaType: pictureData.type }], + isPseudo: true + }; + setPost(pseudoPost); + setMediaItems(pseudoPost.pictures!); + setMediaItem(pseudoPost.pictures![0]); + setLikesCount(pictureData.likes_count || 0); + return; + } + + // 3. Try fetching as a Page (for page-intern items) + try { + const pageData = await fetchPageById(id!); + if (pageData) { + const pseudoPost: PostItem = { + id: pageData.id, + title: pageData.title, + description: null, + user_id: pageData.owner, + created_at: pageData.created_at, + updated_at: pageData.created_at, + pictures: [], + isPseudo: true, + type: 'page-intern', + meta: { slug: pageData.slug } + }; + setPost(pseudoPost); + setLoading(false); + return; + } + } catch (e) { + console.error("Error fetching page:", e); + } + + toast.error(translate('Content not found')); + navigate('/'); + } catch (error) { + console.error('Error fetching content:', error); + toast.error(translate('Failed to load content')); + navigate('/'); + } finally { + setLoading(false); + } + }; + + const actions = usePostActions({ + post, + mediaItems, + setMediaItems, + mediaItem, + user, + fetchMedia + }); + + const handleYouTubeAdd = async () => { + const videoId = getYouTubeId(youTubeUrl); + if (!videoId) { + toast.error(translate("Invalid YouTube URL")); + return; + } + const embedUrl = `https://www.youtube.com/embed/${videoId}`; + const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; + const newVideoItem: any = { + id: crypto.randomUUID(), + type: 'youtube', + image_url: embedUrl, + thumbnail_url: thumbnailUrl, + title: 'YouTube Video', + description: '', + user_id: user?.id || '', + created_at: new Date().toISOString(), + likes_count: 0 + }; + if (insertIndexRef.current !== -1) { + const newItems = [...localMediaItems]; + newItems.splice(insertIndexRef.current, 0, newVideoItem); + updateMediaPositions(newItems, setLocalMediaItems, setMediaItems); + } else { + updateMediaPositions([...localMediaItems, newVideoItem], setLocalMediaItems, setMediaItems); + } + setYouTubeUrl(''); + actions.setShowYouTubeDialog(false); + toast.success(translate("YouTube video added")); + }; + + const handleTikTokAdd = async () => { + const videoId = getTikTokId(tikTokUrl); + if (!videoId) { + toast.error(translate("Invalid TikTok URL")); + return; + } + const embedUrl = `https://www.tiktok.com/embed/v2/${videoId}`; + const thumbnailUrl = `https://sf16-scmcdn-sg.ibytedtos.com/goofy/tiktok/web/node/_next/static/images/logo-dark-e95da587b6efa1520dcd11f4b45c0cf6.svg`; + const newVideoItem: any = { + id: crypto.randomUUID(), + type: 'tiktok', + image_url: embedUrl, + thumbnail_url: thumbnailUrl, + title: 'TikTok Video', + description: '', + user_id: user?.id || '', + created_at: new Date().toISOString(), + likes_count: 0 + }; + if (insertIndexRef.current !== -1) { + const newItems = [...localMediaItems]; + newItems.splice(insertIndexRef.current, 0, newVideoItem); + updateMediaPositions(newItems, setLocalMediaItems, setMediaItems); + } else { + updateMediaPositions([...localMediaItems, newVideoItem], setLocalMediaItems, setMediaItems); + } + setTikTokUrl(''); + actions.setShowTikTokDialog(false); + toast.success(translate("TikTok video added")); + }; + + + const handleLike = async () => { + if (!user || !mediaItem) { + toast.error(translate('Please sign in to like this')); + return; + } + try { + const isNowLiked = await toggleLike(user.id, mediaItem.id, isLiked); + setIsLiked(isNowLiked); + setLikesCount(prev => isNowLiked ? prev + 1 : prev - 1); + + setMediaItems(prevItems => prevItems.map(item => { + if (item.id === mediaItem.id) { + return { + ...item, + is_liked: isNowLiked, + likes_count: (item.likes_count || 0) + (isNowLiked ? 1 : -1) + }; + } + return item; + })); + } catch (error) { + console.error('Error toggling like:', error); + toast.error(translate('Failed to update like')); + } + }; + + const handleDownload = async () => { + await downloadMediaItem(mediaItem, isVideo); + }; + + const handlePublish = async (option: 'overwrite' | 'new' | 'version', imageUrl: string, newTitle: string, description?: string, parentId?: string, collectionIds?: string[]) => { + if (!mediaItem || isVideo || !user) { + toast.error(translate('Please sign in to publish images')); + return; + } + setIsPublishing(true); + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + + if (option === 'overwrite') { + const currentImageUrl = mediaItem.image_url; + if (currentImageUrl.includes('supabase.co/storage/')) { + const urlParts = currentImageUrl.split('/'); + const fileName = urlParts[urlParts.length - 1]; + const bucketPath = `${mediaItem.user_id}/${fileName}`; + await updateStorageFile(bucketPath, blob); + toast.success(translate('Image updated successfully!')); + fetchMedia(); + } else { + toast.error(translate('Cannot overwrite this image')); + return; + } + } else if (option === 'version') { + const publicUrl = await uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-version.png`); + const pictureData = await createPicture({ + title: newTitle?.trim() || null, + description: description || `Generated from: ${mediaItem.title}`, + image_url: publicUrl, + user_id: user.id, + parent_id: parentId || mediaItem.id, + is_selected: false, + visible: false + }); + if (collectionIds && collectionIds.length > 0 && pictureData) { + await addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id }))); + } + toast.success(translate('Version saved successfully!')); + loadVersions(); + } else { + const publicUrl = await uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-generated.png`); + const pictureData = await createPicture({ + title: newTitle?.trim() || null, + description: description || `Generated from: ${mediaItem.title}`, + image_url: publicUrl, + user_id: user.id + }); + if (collectionIds && collectionIds.length > 0 && pictureData) { + await addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id }))); + } + toast.success(translate('Image published to gallery!')); + } + setShowLightbox(false); + // llm state cleared by component + + } catch (error) { + console.error('Error publishing image:', error); + toast.error(translate('Failed to publish image')); + } finally { + setIsPublishing(false); + } + }; + + const handleOpenInWizard = (imageUrl?: string) => { + if (!mediaItem || isVideo) return; + const imageToEdit = imageUrl || mediaItem.image_url; + const imageData = { + id: mediaItem.id, + src: imageToEdit, + title: mediaItem.title, + realDatabaseId: mediaItem.id, + selected: true + }; + setWizardImage(imageData, window.location.pathname); + setShowLightbox(false); + navigate('/wizard'); + }; + + const handleEditPicture = () => { + if (!mediaItem) return; + setShowEditModal(true); + }; + + const handleEditPost = () => { + if (!post) return; + navigate(`/post/${post.id}/edit`); + }; + + const rendererProps = { + post, authorProfile, mediaItems, localMediaItems, mediaItem: mediaItem!, + user, isOwner: !!isOwner, isEditMode, isLiked, likesCount, + localPost, setLocalPost, setLocalMediaItems, + + onEditModeToggle: toggleEditMode, + onEditPost: handleEditPost, + onViewModeChange: handleViewMode, + onExportMarkdown: () => exportMarkdown(post, mediaItem!, mediaItems, authorProfile), + onSaveChanges: handleSaveChanges, + onDeletePost: () => actions.setShowDeletePostDialog(true), + onDeletePicture: () => actions.setShowDeletePictureDialog(true), + onLike: handleLike, + onUnlinkImage: actions.handleUnlinkImage, + onRemoveFromPost: handleRemoveFromPost, + onEditPicture: handleEditPicture, + onGalleryPickerOpen: openGalleryPicker, + onYouTubeAdd: () => actions.setShowYouTubeDialog(true), + onTikTokAdd: () => actions.setShowTikTokDialog(true), + onAIWizardOpen: openAIWizard, + onInlineUpload: handleInlineUpload, + onMoveItem: moveItem, + onMediaSelect: setMediaItem, + onExpand: (item: MediaItem) => { setMediaItem(item); setShowLightbox(true); }, + onDownload: handleDownload, + onCategoryManagerOpen: () => actions.setShowCategoryManager(true), + + + currentImageIndex, + videoPlaybackUrl, + videoPosterUrl, + versionImages, + + handlePrevImage, + embedded + }; + + // Render Page Content if it's an internal page + if (post?.type === 'page-intern' && post.meta?.slug) { + return ( + + ); + } + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!mediaItem) { + return ( +
+
+
Content not found
+ {!embedded && } +
+
+ ); + } + + const containerClassName = embedded + ? `flex flex-col bg-background h-[inherit] ${className || ''}` + : "bg-background flex flex-col"; + + // Desktop (lg+): fixed height for split gallery/details. Below lg: let content define height so + //
(between nav + global footer) isn't shorter than this box — overflow was painting over the footer. + if (!embedded && !className) { + className = "max-lg:h-auto lg:h-[calc(100vh-4rem)]" + } + /* + console.log('containerClassName', containerClassName); + console.log('className', className); + console.log('embedded', embedded); +*/ + + return ( +
+ {post && ( + + )} +
+ + {viewMode === 'thumbs' ? ( + + ) : ( + + )} +
+ + Loading editor...
}> + {showEditModal && !isVideo && ( + { + setShowEditModal(false); + fetchMedia(); + }} + /> + )} + + {showEditModal && isVideo && ( + { + setShowEditModal(false); + fetchMedia(); + }} + /> + )} + + + { + !isVideo && showLightbox && ( + Loading Lightbox...}> + { + setShowLightbox(false); + // Restore focus to the element that was focused before lightbox opened + requestAnimationFrame(() => { + //preLightboxFocusRef.current?.focus(); + //preLightboxFocusRef.current = null; + }); + }} + mediaItem={mediaItem} + user={user} + isVideo={false} + onPublish={handlePublish} + onNavigate={(direction) => { + if (direction === 'next') handleNextImage(); + else handlePrevImage(); + }} + onOpenInWizard={() => handleOpenInWizard()} // SmartLightbox handles the argument if needed, or we adapt + currentIndex={currentImageIndex} + totalCount={mediaItems.length} + /> + + ) + } + + {/* Dialogs */} + + + + + + + + + + {showEditModal && mediaItem && ( + { + fetchMedia(); + setShowEditModal(false); + }} + /> + )} + + setShowGalleryPicker(false)} + onSelect={handleGallerySelect} + /> + + {showAIWizard && ( +
+
+ + setShowAIWizard(false)} + mode="default" + initialPostTitle={post?.title || ""} + initialPostDescription={post?.description || ""} + onPublish={handleAIWizardPublish as any} + /> +
+
+ )} + + actions.setShowCategoryManager(false)} + currentPageId={post?.id} + currentPageMeta={post?.meta} + onPageMetaUpdate={actions.handleMetaUpdate} + filterByType="pages" + defaultMetaType="pages" + /> +
+ + ); +}; + +export default Post; diff --git a/packages/ui/src/modules/posts/views/renderers/components/CompactPostHeader.tsx b/packages/ui/src/modules/posts/views/renderers/components/CompactPostHeader.tsx index d8e5dd22..69bedc6a 100644 --- a/packages/ui/src/modules/posts/views/renderers/components/CompactPostHeader.tsx +++ b/packages/ui/src/modules/posts/views/renderers/components/CompactPostHeader.tsx @@ -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 = ({ - 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 ? ( -
- setLocalPost && setLocalPost({ ...localPost!, title: e.target.value })} - className="font-bold text-lg" - placeholder="Post Title" - /> -