mono/packages/ui/src/pages/Post.tsx

1116 lines
39 KiB
TypeScript

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 { usePostNavigation } from "@/hooks/usePostNavigation";
import { useWizardContext } from "@/hooks/useWizardContext";
import { T, translate } from "@/i18n";
import { isVideoType } from "@/lib/mediaRegistry";
import { getYouTubeId, getTikTokId, updateMediaPositions, getVideoUrlWithResolution } from "./Post/utils";
import { YouTubeDialog } from "./Post/components/YouTubeDialog";
import { TikTokDialog } from "./Post/components/TikTokDialog";
import { ArticleRenderer } from "./Post/renderers/ArticleRenderer";
import UserPage from "@/pages/UserPage";
import { ThumbsRenderer } from "./Post/renderers/ThumbsRenderer";
import { CompactRenderer } from "./Post/renderers/CompactRenderer";
import { usePostActions } from "./Post/usePostActions";
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
import { DeleteDialog } from "./Post/components/DeleteDialogs";
import { CategoryManager } from "@/components/widgets/CategoryManager";
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 "./Post/types";
import * as db from "./Post/db";
import { ImageFile, MediaType } from "@/types";
import { uploadInternalVideo } from "@/utils/uploadUtils";
// 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("./Post/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 { navigationData, setNavigationData } = usePostNavigation();
const { setWizardImage } = useWizardContext();
// ... state ...
const [post, setPost] = useState<PostItem | null>(null);
const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
const [mediaItem, setMediaItem] = useState<MediaItem | null>(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 [isPublishing, setIsPublishing] = useState(false);
const [versionImages, setVersionImages] = useState<ImageFile[]>([]);
// Don't calculate currentImageIndex here yet, wait for render or use memo
const [authorProfile, setAuthorProfile] = useState<UserProfile | null>(null);
const [youTubeUrl, setYouTubeUrl] = useState('');
const [tikTokUrl, setTikTokUrl] = useState('');
// NOTE: llm hook removed from here, now inside SmartLightbox
const isVideo = isVideoType(mediaItem?.type);
// Initialize viewMode from URL parameter
const [viewMode, setViewMode] = useState<'compact' | 'article' | 'thumbs'>(() => {
const viewParam = searchParams.get('view');
if (viewParam === 'article' || 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);
// Sync state to URL
// Sync state to URL
useEffect(() => {
// Only update view parameter
const newParams = new URLSearchParams(searchParams);
// Always sync view mode
newParams.set('view', viewMode);
// Remove selected if it exists (cleanup) or just ignore it
newParams.delete('selected');
const currentView = searchParams.get('view');
if (currentView !== viewMode) {
setSearchParams(newParams, { replace: true });
}
}, [viewMode]);
// Sync URL to state (on mount / params change)
useEffect(() => {
const viewParam = searchParams.get('view');
if (viewParam === 'article' || viewParam === 'compact' || viewParam === 'thumbs') {
setViewMode(viewParam as any);
}
}, [searchParams]);
const [removedItemIds, setRemovedItemIds] = useState<Set<string>>(new Set());
// Inline Editor State
const [isEditMode, setIsEditMode] = useState(false);
const [localPost, setLocalPost] = useState<{ title: string; description: string } | null>(null);
const [localMediaItems, setLocalMediaItems] = useState<any[]>([]);
const [showGalleryPicker, setShowGalleryPicker] = useState(false);
const [showAIWizard, setShowAIWizard] = useState(false);
const insertIndexRef = useRef<number>(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 === 'article' || savedMode === 'thumbs') {
setViewMode(savedMode as any);
} else if (post?.settings?.display) {
setViewMode(post.settings.display);
}
}, [post]);
const handleViewMode = (mode: 'compact' | 'article' | '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 db.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 db.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 db.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 || '',
});
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 () => {
await db.updatePostDetails(post.id, {
title: localPost.title,
description: localPost.description,
});
if (removedItemIds.size > 0) {
await db.unlinkPictures(Array.from(removedItemIds));
}
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 db.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 handleNavigate = (direction: 'prev' | 'next') => {
// Note: Lightbox navigation check removed here as it's now handled in SmartLightbox or via lack of prompt
if (!navigationData || !navigationData.posts.length) {
toast.error(translate('Navigation not available - please return to the feed to browse between posts'));
return;
}
const newIndex = direction === 'next' ? navigationData.currentIndex + 1 : navigationData.currentIndex - 1;
if (newIndex >= 0 && newIndex < navigationData.posts.length) {
const newPost = navigationData.posts[newIndex];
setNavigationData({ ...navigationData, currentIndex: newIndex });
navigate(`/post/${newPost.id}`);
} else {
toast.info(`No ${direction === 'next' ? 'next' : 'previous'} post available`);
}
};
const loadVersions = async () => {
if (!mediaItem || isVideo) return;
try {
const allImages = await db.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]);
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, user]);
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 handleNavigate('prev');
} else if (e.key === 'ArrowRight') {
e.preventDefault();
if (currentImageIndex < mediaItems.length - 1) handleNextImage();
else handleNavigate('next');
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [navigationData, showLightbox, currentImageIndex, mediaItems]);
const fetchMedia = async () => {
const resolveVersions = async (items: any[]) => {
if (!items.length) return items;
try {
const rootIds = items.map((i: any) => i.parent_id || i.id);
const allVersions = await db.fetchRelatedVersions(rootIds);
// Map root ID to position to preserve order
const rootPositionMap = new globalThis.Map();
items.forEach((i: any) => {
const rootId = i.parent_id || i.id;
rootPositionMap.set(rootId, i.position);
});
// Use allVersions as the source of truth
let augmentedVersions = (allVersions || []).map((v: any) => {
const rootId = v.parent_id || v.id;
const pos = rootPositionMap.get(rootId);
return {
...v,
position: pos !== undefined ? pos : 9999, // default to end if unknown
type: v.type as MediaType,
renderKey: v.id
};
});
// Fallback
if (!augmentedVersions || augmentedVersions.length === 0) {
augmentedVersions = items;
}
// Sort by position matching the original items, then by created_at for versions
augmentedVersions.sort((a: any, b: any) => {
const posDiff = (a.position || 0) - (b.position || 0);
if (posDiff !== 0) return posDiff;
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
// Deduplicate
const seenIds = new Set();
return augmentedVersions.filter((item: any) => {
if (seenIds.has(item.id)) return false;
seenIds.add(item.id);
return true;
});
} catch (e) {
console.error("Error resolving versions", e);
return items;
}
};
try {
const postData = await db.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 = await resolveVersions(items);
items = items.filter((item: any) => item.visible || user?.id === item.user_id);
if (user) {
try {
const likedIds = await db.fetchUserLikesForPictures(user.id, items.map((i: any) => i.id));
items = items.map((item: any) => ({
...item,
is_liked: likedIds.includes(item.id)
}));
} catch (e) {
console.error("Error fetching likes", e);
}
}
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);
setMediaItem(items[0]);
setLikesCount(items[0].likes_count || 0);
} else {
toast.error('This post has no media');
}
return;
}
const pictureData = await db.fetchPictureById(id!);
if (pictureData) {
if (pictureData.post_id) {
const fullPostData = await db.fetchFullPost(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));
items = await resolveVersions(items);
items = items.filter((item: any) => item.visible || user?.id === item.user_id);
setPost({ ...fullPostData, 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 {
setMediaItem(items[0]);
setLikesCount(items[0].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 }],
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 db.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 db.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 db.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 db.uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-version.png`);
const pictureData = await db.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 db.addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id })));
}
toast.success(translate('Version saved successfully!'));
loadVersions();
} else {
const publicUrl = await db.uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-generated.png`);
const pictureData = await db.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 db.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 || !mediaItems.length) return;
const wizardImages = mediaItems.map(item => {
const isVideo = item.type === 'mux-video';
const meta = item.meta as any || {};
return {
id: item.id,
path: item.id,
src: isVideo ? (item.thumbnail_url || item.image_url) : item.image_url,
title: item.title,
description: item.description || '',
selected: false,
realDatabaseId: item.id,
type: isVideo ? 'video' : 'image',
uploadStatus: isVideo ? 'ready' : undefined,
muxUploadId: isVideo ? meta.mux_upload_id : undefined,
muxAssetId: isVideo ? meta.mux_asset_id : undefined,
muxPlaybackId: isVideo ? meta.mux_playback_id : undefined,
};
});
navigate('/wizard', {
state: {
mode: 'post',
initialImages: wizardImages,
postTitle: post.title,
postDescription: post.description,
editingPostId: post.id
}
});
};
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,
handleNavigate,
navigationData,
embedded
};
// Render Page Content if it's an internal page
if (post?.type === 'page-intern' && post.meta?.slug) {
return (
<UserPage
userId={post.user_id}
slug={post.meta.slug}
embedded
/>
);
}
if (loading) {
return (
<div className={embedded ? "flex items-center justify-center p-8" : "min-h-screen bg-background flex items-center justify-center"}>
<div className="text-muted-foreground"><T>Loading...</T></div>
</div>
);
}
if (!mediaItem) {
return (
<div className={embedded ? "flex items-center justify-center p-8" : "min-h-screen bg-background flex items-center justify-center"}>
<div className="text-center space-y-4">
<div className="text-muted-foreground text-lg"><T>Content not found</T></div>
{!embedded && <Button onClick={() => navigate('/')} variant="outline"><T>Go Home</T></Button>}
</div>
</div>
);
}
const containerClassName = embedded
? `flex flex-col bg-background h-full ${className || ''}`
: "bg-background flex flex-col h-full";
return (
<div className={containerClassName}>
<div className={embedded ? "w-full h-full" : "w-full max-w-[1600px] mx-auto"}>
{viewMode === 'article' ? (
<ArticleRenderer {...rendererProps} mediaItem={mediaItem} />
) : viewMode === 'thumbs' ? (
<ThumbsRenderer {...rendererProps} />
) : (
<CompactRenderer {...rendererProps} mediaItem={mediaItem} />
)}
</div>
<Suspense fallback={<div className="hidden">Loading editor...</div>}>
{showEditModal && !isVideo && (
<EditImageModal
open={showEditModal}
onOpenChange={setShowEditModal}
pictureId={mediaItem.id}
currentTitle={mediaItem.title}
currentDescription={mediaItem.description}
currentVisible={(mediaItem as any).visible ?? true}
imageUrl={mediaItem.image_url}
onUpdateSuccess={() => {
setShowEditModal(false);
fetchMedia();
}}
/>
)}
{showEditModal && isVideo && (
<EditVideoModal
open={showEditModal}
onOpenChange={setShowEditModal}
videoId={mediaItem.id}
currentTitle={mediaItem.title}
currentDescription={mediaItem.description}
currentVisible={(mediaItem as any).visible ?? true}
onUpdateSuccess={() => {
setShowEditModal(false);
fetchMedia();
}}
/>
)}
</Suspense>
{
!isVideo && showLightbox && (
<Suspense fallback={<div className="fixed inset-0 z-[100] bg-background/50 backdrop-blur-sm flex items-center justify-center">Loading Lightbox...</div>}>
<SmartLightbox
isOpen={showLightbox}
onClose={() => setShowLightbox(false)}
mediaItem={mediaItem}
user={user}
isVideo={false}
onPublish={handlePublish}
onNavigate={handleNavigate}
onOpenInWizard={() => handleOpenInWizard()} // SmartLightbox handles the argument if needed, or we adapt
currentIndex={navigationData?.currentIndex}
totalCount={navigationData?.posts.length}
/>
</Suspense>
)
}
{/* Dialogs */}
<YouTubeDialog
open={actions.showYouTubeDialog}
onOpenChange={actions.setShowYouTubeDialog}
url={youTubeUrl}
onUrlChange={setYouTubeUrl}
onConfirm={handleYouTubeAdd}
/>
<TikTokDialog
open={actions.showTikTokDialog}
onOpenChange={actions.setShowTikTokDialog}
url={tikTokUrl}
onUrlChange={setTikTokUrl}
onConfirm={handleTikTokAdd}
/>
<DeleteDialog
open={actions.showDeletePostDialog}
onOpenChange={actions.setShowDeletePostDialog}
onConfirm={actions.handleDeletePost}
title="Delete Post"
description="Are you sure you want to delete this post? This action cannot be undone."
/>
<DeleteDialog
open={actions.showDeletePictureDialog}
onOpenChange={actions.setShowDeletePictureDialog}
onConfirm={actions.handleDeletePicture}
title="Delete Picture"
description="Are you sure you want to delete this picture? This action cannot be undone."
/>
<Suspense fallback={null}>
{showEditModal && mediaItem && (
<EditImageModal
open={showEditModal}
onOpenChange={setShowEditModal}
pictureId={mediaItem.id}
currentTitle={mediaItem.title}
currentDescription={mediaItem.description || null}
currentVisible={true} // Defaulting to true as visible prop might be on backend or handled otherwise
imageUrl={mediaItem.image_url}
onUpdateSuccess={() => {
fetchMedia();
setShowEditModal(false);
}}
/>
)}
<ImagePickerDialog
isOpen={showGalleryPicker}
onClose={() => setShowGalleryPicker(false)}
onSelect={handleGallerySelect}
/>
{showAIWizard && (
<div className="fixed inset-0 z-[9999] bg-background/95 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full h-full max-w-[1600px] bg-background border rounded-xl overflow-hidden shadow-2xl relative">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-50"
onClick={() => setShowAIWizard(false)}
>
<X className="h-6 w-6" />
</Button>
<ImageWizard
key="inline-wizard"
isOpen={true}
onClose={() => setShowAIWizard(false)}
mode="default"
initialPostTitle={post?.title || ""}
initialPostDescription={post?.description || ""}
onPublish={handleAIWizardPublish as any}
/>
</div>
</div>
)}
<CategoryManager
isOpen={actions.showCategoryManager}
onClose={() => actions.setShowCategoryManager(false)}
currentPageId={post?.id}
currentPageMeta={post?.meta}
onPageMetaUpdate={actions.handleMetaUpdate}
filterByType="pages"
defaultMetaType="pages"
/>
</Suspense>
</div>
);
};
export default Post;