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

- {/* 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
+ <>
+

+ {/* 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 (
-
- );
- }
-
- 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 (
+
+ );
+ }
+
+ 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)}>
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
- {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)}>
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {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 (
-
- );
- }
-
- 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 (
+
+ );
+ }
+
+ 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 ? (
-
- ) : (
-
-
- {/* Left column: title, description, categories */}
-
- {post && (post.description || (post.title && post.title !== mediaItem?.title)) && (
- <>
- {post.title && post.title !== mediaItem?.title && (
{post.title}
)}
- {post.description &&
}
-
- {/* Category Breadcrumbs */}
- {(() => {
- const displayPaths = (post as any).category_paths || [];
- if (displayPaths.length === 0) return null;
-
- return (
-
- {displayPaths.map((path: any[], pathIdx: number) => (
-
-
- {path.map((cat: any, idx: number) => (
-
- {idx > 0 && /}
-
- {cat.name}
-
-
- ))}
-
- ))}
-
- );
- })()}
- >
- )}
-
-
- {/* Right column: actions */}
-
-
-
-
-
-
- {/* Open Standalone - break out of embedded view */}
- {embedded && post?.id && (
-
- )}
-
-
onExportMarkdown('raw')}
- />
-
- {isOwner && (
- <>
-
-
-
-
-
-
- Edit Post Wizard
- {onCategoryManagerOpen && Manage Categories}
- Delete this picture
- Delete whole post
-
-
- >
- )}
-
-
-
- )}
-
- {/* Author / Date Row */}
-
-
-
- >
- );
-};
+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 ? (
+
+ ) : (
+
+ {/* Full-width text, then toolbar on its own row — avoids squeezing description next to Export / wide controls */}
+
+ {/* Title, description, categories — always full width of the column */}
+
+ {post && (post.description || (post.title && post.title !== mediaItem?.title)) && (
+ <>
+ {post.title && post.title !== mediaItem?.title && (
{post.title}
)}
+ {post.description &&
}
+
+ {/* Category Breadcrumbs */}
+ {(() => {
+ const displayPaths = (post as any).category_paths || [];
+ if (displayPaths.length === 0) return null;
+
+ return (
+
+ {displayPaths.map((path: any[], pathIdx: number) => (
+
+
+ {path.map((cat: any, idx: number) => (
+
+ {idx > 0 && /}
+
+ {cat.name}
+
+
+ ))}
+
+ ))}
+
+ );
+ })()}
+ >
+ )}
+
+
+ {/* Actions row — does not share a horizontal flex row with markdown */}
+
+
+
+
+
+
+ {/* Open Standalone - break out of embedded view */}
+ {embedded && post?.id && (
+
+ )}
+
+
onExportMarkdown('raw')}
+ />
+
+ {isOwner && (
+ <>
+
+
+
+
+
+
+ Edit Post Wizard
+ {onCategoryManagerOpen && Manage Categories}
+ Delete this picture
+ Delete whole post
+
+
+ >
+ )}
+
+
+
+ )}
+
+ {/* Author / Date Row */}
+
+
+
+ >
+ );
+};