(null);
+ const { user } = useAuth();
+
+ // Helper to resolve relative URLs
+ const resolveUrl = useCallback((url: string | undefined) => {
+ if (!url) return '';
+ if (!baseUrl) return url;
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
+
+ // Resolve relative path against baseUrl
+ try {
+ // If baseUrl is relative, make it absolute using the API origin so the server can fetch it
+ let absoluteBase = baseUrl;
+ if (baseUrl.startsWith('/')) {
+ const apiOrigin = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
+ // if API url is absolute (http://...), use it as the base.
+ // fallback to window.location.origin for relative API configs.
+ const originToUse = apiOrigin.startsWith('http') ? apiOrigin : window.location.origin;
+ // Avoid double-prefixing if baseUrl already contains the origin root (e.g. from SSR)
+ if (!baseUrl.startsWith(originToUse)) {
+ absoluteBase = `${originToUse}${baseUrl}`;
+ }
+ }
+
+ // Ensure the base URL resolves to the directory, not the file
+ // URL constructor resolves './file' relative to the path. If path doesn't end in '/',
+ // it strips the last segment. So we DO NOT want to arbitrarily append '/' to a file path.
+ // If absoluteBase is a file path (e.g. '.../document.md'), the URL constructor natively handles:
+ // new URL('./image.jpg', '.../document.md') => '.../image.jpg'
+ return new URL(url, absoluteBase).href;
+ } catch {
+ return url; // Fallback if parsing fails
+ }
+ }, [baseUrl]);
+
+ // Substitute variables in content if provided
+ const finalContent = useMemo(() => {
+ const withoutFrontmatter = stripFrontmatter(content);
+ if (!variables || Object.keys(variables).length === 0) return withoutFrontmatter;
+ return substitute(false, withoutFrontmatter, variables);
+ }, [content, variables]);
+
+ // Lightbox state
+ const [lightboxOpen, setLightboxOpen] = useState(false);
+ const [currentImageIndex, setCurrentImageIndex] = useState(0);
+
+ // Extract all images from content for navigation
+ const allImages = useMemo(() => {
+ const images: { src: string, alt: string }[] = [];
+ const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
+ let match;
+ // We clone the regex to avoid stateful issues if reuse happens, though local var is fine
+ const localRegex = new RegExp(regex);
+ while ((match = localRegex.exec(finalContent)) !== null) {
+ images.push({
+ alt: match[1],
+ src: match[2]
+ });
+ }
+ return images;
+ }, [finalContent]);
+
+ // Memoize content analysis (keep existing logic for simple hashtag views)
+ const contentAnalysis = useMemo(() => {
+ const hasHashtags = /#[a-zA-Z0-9_]+/.test(finalContent);
+ const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(finalContent);
+ const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(finalContent);
+
+ return {
+ hasHashtags,
+ hasMarkdownLinks,
+ hasMarkdownSyntax
+ };
+ }, [finalContent]);
+
+ // Removed Prism.highlightAllUnder to prevent React NotFoundError during streaming
+ // Highlighting is now handled safely within the `code` component renderer.
+
+ const handleImageClick = (src: string) => {
+ const index = allImages.findIndex(img => img.src === src);
+ if (index !== -1) {
+ setCurrentImageIndex(index);
+ setLightboxOpen(true);
+ }
+ };
+
+ const handleNavigate = (direction: 'prev' | 'next') => {
+ if (direction === 'prev') {
+ setCurrentImageIndex(prev => (prev > 0 ? prev - 1 : prev));
+ } else {
+ setCurrentImageIndex(prev => (prev < allImages.length - 1 ? prev + 1 : prev));
+ }
+ };
+
+ // Mock MediaItem for SmartLightbox
+ const mockMediaItem = useMemo((): PostMediaItem | null => {
+ const selectedImage = allImages[currentImageIndex];
+ if (!selectedImage) return null;
+ const resolvedUrl = resolveUrl(selectedImage.src);
+ return {
+ id: 'md-' + btoa(encodeURIComponent(selectedImage.src)).substring(0, 10), // stable ID based on SRC
+ title: selectedImage.alt || 'Image',
+ description: '',
+ image_url: resolvedUrl,
+ thumbnail_url: resolvedUrl,
+ user_id: user?.id || 'unknown',
+ type: 'image',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ position: 0,
+ likes_count: 0,
+ post_id: null
+ } as any;
+ }, [currentImageIndex, allImages, user, resolveUrl]);
+
+ // Only use HashtagText if content has hashtags but NO markdown syntax at all
+ if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
+ return (
+
+ {finalContent}
+
+ );
+ }
+
+ return (
+ <>
+
+
{
+ // Basic implementation of ResponsiveImage
+ const resolvedSrc = resolveUrl(src);
+
+ return (
+
+ resolvedSrc && handleImageClick(src || '')}
+ />
+ {title && {title}}
+
+ );
+ },
+ a: ({ node, href, children, ...props }) => {
+ if (!href) return {children};
+
+ // Logic to format display text if it matches the URL
+ let childText = '';
+ if (typeof children === 'string') {
+ childText = children;
+ } else if (Array.isArray(children) && children.length > 0 && typeof children[0] === 'string') {
+ // Simple approximation for React children
+ childText = children[0];
+ }
+
+ const isAutoLink = childText === href || childText.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, '');
+ const displayContent = isAutoLink ? formatUrlDisplay(href) : children;
+
+ const isRelative = !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:') && !href.startsWith('tel:') && !href.startsWith('data:') && !href.startsWith('#');
+
+ return (
+ {
+ if (onLinkClick) {
+ onLinkClick(href, e);
+ }
+ }}
+ {...props}
+ >
+ {displayContent}
+
+ );
+ },
+ h1: ({ node, children, ...props }) => {
+ const text = getPlainText(children);
+ const id = slugify(text);
+ return {children}
;
+ },
+ h2: ({ node, children, ...props }) => {
+ const text = getPlainText(children);
+ const id = slugify(text);
+ return {children}
;
+ },
+ h3: ({ node, children, ...props }) => {
+ const text = getPlainText(children);
+ const id = slugify(text);
+ return {children}
;
+ },
+ h4: ({ node, children, ...props }) => {
+ const text = getPlainText(children);
+ const id = slugify(text);
+ return {children}
;
+ },
+ p: ({ node, children, ...props }) => {
+ // Check if the paragraph contains an image
+ // @ts-ignore
+ const hasImage = node?.children?.some((child: any) =>
+ child.type === 'element' && child.tagName === 'img'
+ );
+
+ if (hasImage) {
+ return {children}
;
+ }
+ return {children}
;
+ },
+ table: ({ node, ...props }) => (
+
+ ),
+ thead: ({ node, ...props }) => (
+
+ ),
+ th: ({ node, ...props }) => (
+ |
+ ),
+ td: ({ node, ...props }) => (
+ |
+ ),
+ // Custom component: ```custom-gallery\nid1,id2,id3\n```
+ code: ({ node, className, children, ...props }) => {
+ if (className === 'language-mermaid') {
+ const chart = String(children).trim();
+ return (
+ Loading Mermaid diagram... }>
+
+
+ );
+ }
+
+ if (className === 'language-custom-gallery') {
+ const ids = String(children).trim().split(/[,\s\n]+/).filter(Boolean);
+ if (ids.length > 0) {
+ return (
+ }>
+
+
+ );
+ }
+ }
+
+ const match = /language-(\w+)/.exec(className || '');
+ const language = match ? match[1] : '';
+
+ if (!match) {
+ // Inline code or unclassified code
+ return {children};
+ }
+
+ const text = String(children).replace(/\n$/, '');
+
+ // Handle common language aliases
+ let prismLang = language;
+ if (language === 'ts') prismLang = 'typescript';
+ if (language === 'js') prismLang = 'javascript';
+ if (language === 'sh') prismLang = 'bash';
+ if (language === 'html' || language === 'xml') prismLang = 'markup';
+
+ if (Prism.languages[prismLang]) {
+ try {
+ const html = Prism.highlight(text, Prism.languages[prismLang], prismLang);
+ return (
+
+ );
+ } catch (e) {
+ console.error('Prism highlight error', e);
+ }
+ }
+
+ // Fallback to unhighlighted
+ return {children};
+ },
+ // Unwrap for custom components (gallery etc.)
+ pre: ({ node, children, ...props }) => {
+ // Check the actual AST node type to see if it's our custom gallery
+ const firstChild = node?.children?.[0];
+ if (firstChild?.type === 'element' && firstChild?.tagName === 'code') {
+ const isGallery = Array.isArray(firstChild.properties?.className)
+ && firstChild.properties?.className.includes('language-custom-gallery');
+ const isMermaid = Array.isArray(firstChild.properties?.className)
+ && firstChild.properties?.className.includes('language-mermaid');
+
+ if (isGallery || isMermaid) {
+ return <>{children}>;
+ }
+
+ // Normal code block
+ return {children};
+ }
+
+ // Fallback
+ return {children};
+ },
+ }}
+ >
+ {finalContent}
+
+
+
+ {lightboxOpen && mockMediaItem && (
+
+ setLightboxOpen(false)}
+ mediaItem={mockMediaItem}
+ imageUrl={mockMediaItem.image_url}
+ imageTitle={mockMediaItem.title}
+ user={user}
+ isVideo={false}
+ // Dummy handlers for actions that aren't supported in this context
+ onPublish={async () => { }}
+ onNavigate={handleNavigate}
+ onOpenInWizard={() => { }}
+ currentIndex={currentImageIndex}
+ totalCount={allImages.length}
+ />
+
+ )}
+ >
+ );
+});
+
+MarkdownRenderer.displayName = 'MarkdownRenderer';
+
+export default MarkdownRenderer;
diff --git a/packages/ui/src/components/OrganizationsList.tsx b/packages/ui/src/components/OrganizationsList.tsx
index c9a9b671..b23b95f4 100644
--- a/packages/ui/src/components/OrganizationsList.tsx
+++ b/packages/ui/src/components/OrganizationsList.tsx
@@ -1,73 +1,73 @@
-import { useQuery } from "@tanstack/react-query";
-import { supabase } from "@/integrations/supabase/client";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Building2 } from "lucide-react";
-import { Link } from "react-router-dom";
-
-const OrganizationsList = () => {
- const { data: organizations, isLoading } = useQuery({
- queryKey: ["organizations"],
- queryFn: async () => {
- const { data, error } = await supabase
- .from("organizations")
- .select("*")
- .order("created_at", { ascending: false });
-
- if (error) throw error;
- return data;
- },
- });
-
- if (isLoading) {
- return (
-
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
- ))}
-
- );
- }
-
- if (!organizations || organizations.length === 0) {
- return null;
- }
-
- return (
-
-
-
-
- Organizations
-
-
-
- Explore creative communities and their collections
-
-
-
- {organizations.map((org) => (
-
-
-
-
- {org.name}
- @{org.slug}
-
-
-
- ))}
-
-
- );
-};
-
-export default OrganizationsList;
+import { useQuery } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Building2 } from "lucide-react";
+import { Link } from "react-router-dom";
+
+const OrganizationsList = () => {
+ const { data: organizations, isLoading } = useQuery({
+ queryKey: ["organizations"],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("organizations")
+ .select("*")
+ .order("created_at", { ascending: false });
+
+ if (error) throw error;
+ return data;
+ },
+ });
+
+ if (isLoading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (!organizations || organizations.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Organizations
+
+
+
+ Explore creative communities and their collections
+
+
+
+ {organizations.map((org) => (
+
+
+
+
+ {org.name}
+ @{org.slug}
+
+
+
+ ))}
+
+
+ );
+};
+
+export default OrganizationsList;
diff --git a/packages/ui/src/components/PhotoCard.tsx b/packages/ui/src/components/PhotoCard.tsx
index f9ed912e..eec532b2 100644
--- a/packages/ui/src/components/PhotoCard.tsx
+++ b/packages/ui/src/components/PhotoCard.tsx
@@ -1,618 +1,618 @@
-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;
-}
-
-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
-}: 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) && (
-
- {showTitle && title && !isLikelyFilename(title) && (
-
{title}
- )}
- {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}
- )}
-
- {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;
\ No newline at end of file
+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;
+}
+
+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
+}: 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) && (
+
+ {showTitle && title && !isLikelyFilename(title) && (
+
{title}
+ )}
+ {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}
+ )}
+
+ {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;
diff --git a/packages/ui/src/components/PhotoGrid.tsx b/packages/ui/src/components/PhotoGrid.tsx
index d25e595e..8b2b84aa 100644
--- a/packages/ui/src/components/PhotoGrid.tsx
+++ b/packages/ui/src/components/PhotoGrid.tsx
@@ -1,642 +1,642 @@
-import { UserProfile } from '@/modules/posts/views/types';
-import MediaCard from "./MediaCard";
-import React, { useEffect, useState, useRef } from "react";
-import { useAuth } from "@/hooks/useAuth";
-import { useNavigate } from "react-router-dom";
-
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { useFeedCache } from "@/contexts/FeedCacheContext";
-import { useLayoutEffect } from "react";
-
-import { useFeedData } from "@/hooks/useFeedData";
-
-import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
-import { UploadCloud, Maximize, FolderTree } from "lucide-react";
-import { toast } from "sonner";
-import { T } from "@/i18n";
-import type { MediaType } from "@/types";
-import type { CardPreset } from "@/modules/pages/PageCard";
-
-export interface MediaItemType {
- id: string;
- picture_id?: string;
- title: string;
- description: string | null;
- image_url: string;
- thumbnail_url: string | null;
- type: MediaType;
- meta: any | null;
- likes_count: number;
- created_at: string;
- user_id: string;
- comments: { count: number }[];
-
- author?: UserProfile;
- job?: any;
- responsive?: any; // Add responsive data
- versionCount?: number;
- _searchSource?: string;
-}
-
-import type { FeedSortOption } from '@/hooks/useFeedData';
-import { mapFeedPostsToMediaItems, fetchPostById } from "@/modules/posts/client-posts";
-import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
-
-
-interface MediaGridProps {
- customPictures?: MediaItemType[];
- customLoading?: boolean;
- navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget' | 'search';
- navigationSourceId?: string;
- isOwner?: boolean;
- onFilesDrop?: (files: File[]) => void;
- showVideos?: boolean; // Toggle video display (kept for backward compatibility)
- sortBy?: FeedSortOption;
- supabaseClient?: any;
- apiUrl?: string;
- categorySlugs?: string[];
- categoryIds?: string[];
- preset?: CardPreset;
- contentType?: 'posts' | 'pages' | 'pictures' | 'files';
- visibilityFilter?: 'invisible' | 'private';
- center?: boolean;
- columns?: number | 'auto';
-}
-
-const DEFAULT_PRESET: CardPreset = { showTitle: true, showDescription: true };
-
-const MediaGrid = ({
- customPictures,
- customLoading,
- navigationSource = 'home',
- navigationSourceId,
- isOwner = false,
- onFilesDrop,
- showVideos = true,
- sortBy = 'latest',
- supabaseClient,
- apiUrl,
- categorySlugs,
- categoryIds,
- preset = DEFAULT_PRESET,
- contentType,
- visibilityFilter,
- center,
- columns = 'auto'
-}: MediaGridProps) => {
- const { user } = useAuth();
- const navigate = useNavigate();
-
-
-
-
- const { orgSlug, isOrgContext } = useOrganization();
- // State definitions restored
- const { getCache, saveCache } = useFeedCache();
- const [mediaItems, setMediaItems] = useState([]);
- const [userLikes, setUserLikes] = useState>(new Set());
- const [isDragging, setIsDragging] = useState(false);
- const dragLeaveTimeoutRef = useRef(null);
-
- // 1. Data Fetching
- const {
- posts: feedPosts,
- loading: feedLoading,
- hasMore,
- loadMore,
- isFetchingMore
- } = useFeedData({
- source: navigationSource,
- sourceId: navigationSourceId,
- isOrgContext,
- orgSlug,
- sortBy,
- categorySlugs,
- categoryIds,
- contentType,
- visibilityFilter,
- // Disable hook if we have custom pictures
- enabled: !customPictures,
- supabaseClient
- });
-
- // Derive loading from hook/props instead of syncing via setState
- const loading = customPictures ? (customLoading || false) : feedLoading;
-
- // Infinite Scroll Observer
- const observerTarget = useRef(null);
-
- useEffect(() => {
- const observer = new IntersectionObserver(
- entries => {
- if (entries[0].isIntersecting && hasMore && !feedLoading && !isFetchingMore) {
- loadMore();
- }
- },
- { threshold: 1.0 }
- );
-
- if (observerTarget.current) {
- observer.observe(observerTarget.current);
- }
-
- return () => {
- if (observerTarget.current) {
- observer.unobserve(observerTarget.current);
- }
- };
- }, [hasMore, feedLoading, isFetchingMore, loadMore]);
-
- // 2. State & Effects
- useEffect(() => {
- let finalMedia: MediaItemType[] = [];
-
- if (customPictures) {
- finalMedia = customPictures;
- } else {
- // Map FeedPost[] -> MediaItemType[]
- finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy);
- }
-
- setMediaItems(finalMedia);
- }, [feedPosts, customPictures, sortBy]);
-
- // Scroll Restoration Logic
- const cacheKey = `${navigationSource}-${navigationSourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
- const hasRestoredScroll = useRef(false);
-
- // Restore scroll when mediaItems are populated
- useLayoutEffect(() => {
- // Enable restoration if we have items, haven't restored yet, and either it's NOT custom pictures OR it IS a widget with an ID
- const shouldRestore = mediaItems.length > 0 &&
- !hasRestoredScroll.current &&
- (!customPictures || (navigationSource === 'widget' && navigationSourceId));
-
- if (shouldRestore) {
- const cached = getCache(cacheKey);
-
- if (cached && cached.scrollY > 0) {
- window.scrollTo(0, cached.scrollY);
- }
- hasRestoredScroll.current = true;
- }
- }, [mediaItems, cacheKey, getCache, customPictures, navigationSource, navigationSourceId]);
-
- // Reset restored flag when source changes
- useEffect(() => {
- hasRestoredScroll.current = false;
- }, [cacheKey]);
-
- // Track scroll position
- const lastScrollY = useRef(window.scrollY);
-
- useEffect(() => {
- const handleScroll = () => {
- lastScrollY.current = window.scrollY;
- };
- window.addEventListener('scroll', handleScroll);
- return () => window.removeEventListener('scroll', handleScroll);
- }, []);
-
- // Save scroll position on unmount or before source change
- useEffect(() => {
- return () => {
- // Save if not custom pictures OR if it is a widget with an ID
- const shouldSave = !customPictures || (navigationSource === 'widget' && navigationSourceId);
-
- if (shouldSave) {
- const cached = getCache(cacheKey); // Get latest data from cache (posts etc)
- // Use lastScrollY instead of window.scrollY because unmount might happen after a scroll-to-top by router
-
- if (cached && lastScrollY.current > 0) {
- saveCache(cacheKey, { ...cached, scrollY: lastScrollY.current });
- }
- }
- };
- }, [cacheKey, getCache, saveCache, customPictures, navigationSource, navigationSourceId]);
-
- const fetchMediaFromPicturesTable = async () => {
- // Manual Refresh Stub if needed - for onDelete/refresh actions
- // Since the hook data is reactive, we might need a refresh function from the hook
- // But for now, we can just reload the page or implement refresh in future.
- window.location.reload();
- };
-
- const fetchUserLikes = async () => {
- if (!user || mediaItems.length === 0) return;
-
- try {
- const { pictureLikes } = await fetchUserMediaLikes(user.id);
-
- // Filter to only displayed items
- const targetIds = new Set(
- mediaItems.map(item => item.picture_id || item.id).filter(Boolean)
- );
-
- setUserLikes(prev => {
- const newSet = new Set(prev);
- pictureLikes.forEach(id => {
- if (targetIds.has(id)) newSet.add(id);
- });
- return newSet;
- });
- } catch (error) {
- console.error('Error fetching user likes:', error);
- }
- };
-
- const handleMediaClick = (mediaId: string, type: MediaType, index: number) => {
- const item = mediaItems.find(i => i.id === mediaId);
-
- if (item?.meta?.url) {
- navigate(item.meta.url);
- return;
- }
-
- if (type === 'page-intern') {
- if (item && item.meta?.slug) {
- navigate(`/user/${item.author?.username || item.user_id}/pages/${item.meta.slug}`);
- return;
- }
- }
-
- navigate(`/post/${mediaId}`);
- };
-
- const handleDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (dragLeaveTimeoutRef.current) {
- clearTimeout(dragLeaveTimeoutRef.current);
- }
- if (isOwner && onFilesDrop) setIsDragging(true);
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- if (dragLeaveTimeoutRef.current) {
- clearTimeout(dragLeaveTimeoutRef.current);
- }
- dragLeaveTimeoutRef.current = window.setTimeout(() => {
- setIsDragging(false);
- }, 100);
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- };
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragging(false);
-
- if (!isOwner || !onFilesDrop) return;
-
- const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'));
- if (files.length > 0) {
- onFilesDrop(files);
- }
- };
-
- const handleEditPost = async (postId: string) => {
- try {
- const toastId = toast.loading('Loading post for editing...');
-
- // Fetch full post via API (returns FeedPost with pictures)
- const post = await fetchPostById(postId);
-
- if (!post) throw new Error('Post not found');
-
- if (!post.pictures || post.pictures.length === 0) {
- throw new Error('No pictures found in post');
- }
-
- // Transform pictures for wizard
- const wizardImages = post.pictures
- .sort((a: any, b: any) => ((a.position || 0) - (b.position || 0)))
- .map((p: any) => ({
- id: p.id,
- path: p.id,
- src: p.image_url,
- title: p.title,
- description: p.description || '',
- selected: false,
- realDatabaseId: p.id
- }));
-
- toast.dismiss(toastId);
-
- navigate('/wizard', {
- state: {
- mode: 'post',
- initialImages: wizardImages,
- postTitle: post.title,
- postDescription: post.description,
- editingPostId: post.id
- }
- });
- } catch (error) {
- console.error('Error opening post editor:', error);
- toast.error('Failed to open post editor');
- }
- };
-
-
-
-
- // Group media items by category
- // - When filtering by category: group by immediate subcategories (show parent + each child)
- // - When on home feed: don't group (flat list)
- const isSearchTabAllOrFiles = navigationSource === 'search' && (!contentType || contentType === 'files');
- const shouldGroupByCategory = !!categorySlugs && categorySlugs.length > 0;
-
- const getSearchGroup = (item: any): string => {
- if (item.type === 'page-vfs-folder') return 'Folders';
- if (item._searchSource === 'picture') return 'Pictures';
- 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 groupedItems = React.useMemo(() => {
- if (isSearchTabAllOrFiles) {
- const groups = new Map();
- mediaItems.forEach(item => {
- const group = getSearchGroup(item);
- if (!groups.has(group)) groups.set(group, []);
- groups.get(group)!.push(item);
- });
-
- const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files'];
- const sections = [];
- for (const group of orderedGroups) {
- if (groups.has(group)) {
- sections.push({
- key: group,
- title: group,
- items: groups.get(group)!
- });
- }
- }
-
- if (sections.length > 0) return { sections };
- return { sections: [{ key: 'all', title: null, items: mediaItems }] };
- }
-
- if (!shouldGroupByCategory) {
- return { sections: [{ key: 'all', title: null, items: mediaItems }] };
- }
-
- const grouped = new Map();
- const parentCategoryItems: MediaItemType[] = [];
-
- mediaItems.forEach(item => {
- // Find corresponding feed post to get category_paths
- const feedPost = feedPosts.find(p => p.id === item.id || p.cover?.id === item.id);
-
- if (!feedPost?.category_paths || feedPost.category_paths.length === 0) {
- return;
- }
-
- // Check if item has an exact match to the parent category (path length = 1)
- const hasExactParentMatch = feedPost.category_paths.some((path: any[]) =>
- path.length === 1 && categorySlugs.includes(path[0].slug)
- );
-
- if (hasExactParentMatch) {
- // Item is directly in the parent category
- parentCategoryItems.push(item);
- return;
- }
-
- // Otherwise, find the most specific path (longest) that includes the filtered category
- let mostSpecificPath: any[] | null = null;
- let categoryIndex = -1;
-
- for (const path of feedPost.category_paths) {
- const idx = path.findIndex((cat: any) => categorySlugs.includes(cat.slug));
- if (idx !== -1) {
- if (!mostSpecificPath || path.length > mostSpecificPath.length) {
- mostSpecificPath = path;
- categoryIndex = idx;
- }
- }
- }
-
- if (!mostSpecificPath || categoryIndex === -1) return;
-
- // Item is in a subcategory - use the immediate child category
- const subcategory = mostSpecificPath[categoryIndex + 1];
- if (subcategory) {
- const key = subcategory.slug;
-
- if (!grouped.has(key)) {
- grouped.set(key, []);
- }
- grouped.get(key)!.push(item);
- }
- });
-
- const sections = [];
-
- // Add parent category section first (if it has items)
- if (parentCategoryItems.length > 0) {
- sections.push({
- key: categorySlugs[0],
- title: categorySlugs[0].replace(/-/g, ' '),
- items: parentCategoryItems
- });
- }
-
- // Add subcategory sections
- Array.from(grouped.entries()).forEach(([key, items]) => {
- sections.push({
- key,
- title: key.replace(/-/g, ' '),
- items
- });
- });
-
- // If no sections, return all items in one section
- if (sections.length === 0) {
- return { sections: [{ key: 'all', title: null, items: mediaItems }] };
- }
-
- return { sections };
- }, [mediaItems, feedPosts, shouldGroupByCategory, categorySlugs, navigationSource, isSearchTabAllOrFiles]);
-
- if (loading) {
- return (
-
- );
- }
-
- const hasItems = mediaItems.length > 0;
- return (
-
- {hasItems && isOwner && onFilesDrop && navigationSource === 'collection' && (
-
-
-
Drag and drop more images here to upload.
-
- )}
-
- {!hasItems ? (
- isOwner && navigationSource === 'collection' && onFilesDrop ? (
-
-
-
-
This collection is empty
-
Drag and drop images here to get started.
-
-
- ) : (
-
-
-
No media yet!
-
Be the first to share content with the community.
-
-
- )
- ) : (
- <>
- {groupedItems.sections.map((section) => (
-
- {section.title && (
-
-
- {section.title}
-
- )}
- {(() => {
- const isAuto = columns === 'auto';
- const colNum = Number(columns);
- const gridColsClass = isAuto ? 'grid-cols-[repeat(auto-fit,minmax(250px,380px))] justify-center' :
- colNum === 1 ? 'grid-cols-1' :
- colNum === 2 ? 'grid-cols-1 md:grid-cols-2' :
- colNum === 3 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' :
- colNum === 5 ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' :
- colNum === 6 ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6' :
- 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
- return (
-
- {section.items.map((item, index) => {
- const itemType = normalizeMediaType(item.type);
- const isVideo = isVideoType(itemType);
- // For images, convert URL to optimized format
- const displayUrl = item.image_url;
- if (isVideo) {
- return (
-
-
handleMediaClick(item.id, itemType, index)}
- onLike={fetchUserLikes}
- onDelete={fetchMediaFromPicturesTable}
- onEdit={handleEditPost}
- created_at={item.created_at}
- job={item.job}
- responsive={item.responsive}
- apiUrl={apiUrl}
- preset={preset}
- />
- {/* TESTING: group-hover:opacity-100 disabled */}
-
-
-
- );
- }
-
- return (
-
handleMediaClick(item.id, itemType, index)}
- onLike={fetchUserLikes}
- onDelete={fetchMediaFromPicturesTable}
- onEdit={handleEditPost}
-
- created_at={item.created_at}
- job={item.job}
- responsive={item.responsive}
- apiUrl={apiUrl}
- preset={preset}
- />
- );
- })}
-
- );
- })()}
-
- ))}
- >
- )}
-
- {/* Loading Indicator / Observer Target */}
-
- {isFetchingMore && (
-
- )}
-
-
- );
-};
-
-// Backward compatibility export
-export default MediaGrid;
-
-// Named exports for clarity
-export { MediaGrid };
-export const PhotoGrid = MediaGrid;
\ No newline at end of file
+import { UserProfile } from '@/modules/posts/views/types';
+import MediaCard from "./MediaCard";
+import React, { useEffect, useState, useRef } from "react";
+import { useAuth } from "@/hooks/useAuth";
+import { useNavigate } from "react-router-dom";
+
+import { useOrganization } from "@/contexts/OrganizationContext";
+import { useFeedCache } from "@/contexts/FeedCacheContext";
+import { useLayoutEffect } from "react";
+
+import { useFeedData } from "@/hooks/useFeedData";
+
+import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
+import { UploadCloud, Maximize, FolderTree } from "lucide-react";
+import { toast } from "sonner";
+import { T } from "@/i18n";
+import type { MediaType } from "@/types";
+import type { CardPreset } from "@/modules/pages/PageCard";
+
+export interface MediaItemType {
+ id: string;
+ picture_id?: string;
+ title: string;
+ description: string | null;
+ image_url: string;
+ thumbnail_url: string | null;
+ type: MediaType;
+ meta: any | null;
+ likes_count: number;
+ created_at: string;
+ user_id: string;
+ comments: { count: number }[];
+
+ author?: UserProfile;
+ job?: any;
+ responsive?: any; // Add responsive data
+ versionCount?: number;
+ _searchSource?: string;
+}
+
+import type { FeedSortOption } from '@/hooks/useFeedData';
+import { mapFeedPostsToMediaItems, fetchPostById } from "@/modules/posts/client-posts";
+import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
+
+
+interface MediaGridProps {
+ customPictures?: MediaItemType[];
+ customLoading?: boolean;
+ navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget' | 'search';
+ navigationSourceId?: string;
+ isOwner?: boolean;
+ onFilesDrop?: (files: File[]) => void;
+ showVideos?: boolean; // Toggle video display (kept for backward compatibility)
+ sortBy?: FeedSortOption;
+ supabaseClient?: any;
+ apiUrl?: string;
+ categorySlugs?: string[];
+ categoryIds?: string[];
+ preset?: CardPreset;
+ contentType?: 'posts' | 'pages' | 'pictures' | 'files';
+ visibilityFilter?: 'invisible' | 'private';
+ center?: boolean;
+ columns?: number | 'auto';
+}
+
+const DEFAULT_PRESET: CardPreset = { showTitle: true, showDescription: true };
+
+const MediaGrid = ({
+ customPictures,
+ customLoading,
+ navigationSource = 'home',
+ navigationSourceId,
+ isOwner = false,
+ onFilesDrop,
+ showVideos = true,
+ sortBy = 'latest',
+ supabaseClient,
+ apiUrl,
+ categorySlugs,
+ categoryIds,
+ preset = DEFAULT_PRESET,
+ contentType,
+ visibilityFilter,
+ center,
+ columns = 'auto'
+}: MediaGridProps) => {
+ const { user } = useAuth();
+ const navigate = useNavigate();
+
+
+
+
+ const { orgSlug, isOrgContext } = useOrganization();
+ // State definitions restored
+ const { getCache, saveCache } = useFeedCache();
+ const [mediaItems, setMediaItems] = useState([]);
+ const [userLikes, setUserLikes] = useState>(new Set());
+ const [isDragging, setIsDragging] = useState(false);
+ const dragLeaveTimeoutRef = useRef(null);
+
+ // 1. Data Fetching
+ const {
+ posts: feedPosts,
+ loading: feedLoading,
+ hasMore,
+ loadMore,
+ isFetchingMore
+ } = useFeedData({
+ source: navigationSource,
+ sourceId: navigationSourceId,
+ isOrgContext,
+ orgSlug,
+ sortBy,
+ categorySlugs,
+ categoryIds,
+ contentType,
+ visibilityFilter,
+ // Disable hook if we have custom pictures
+ enabled: !customPictures,
+ supabaseClient
+ });
+
+ // Derive loading from hook/props instead of syncing via setState
+ const loading = customPictures ? (customLoading || false) : feedLoading;
+
+ // Infinite Scroll Observer
+ const observerTarget = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries[0].isIntersecting && hasMore && !feedLoading && !isFetchingMore) {
+ loadMore();
+ }
+ },
+ { threshold: 1.0 }
+ );
+
+ if (observerTarget.current) {
+ observer.observe(observerTarget.current);
+ }
+
+ return () => {
+ if (observerTarget.current) {
+ observer.unobserve(observerTarget.current);
+ }
+ };
+ }, [hasMore, feedLoading, isFetchingMore, loadMore]);
+
+ // 2. State & Effects
+ useEffect(() => {
+ let finalMedia: MediaItemType[] = [];
+
+ if (customPictures) {
+ finalMedia = customPictures;
+ } else {
+ // Map FeedPost[] -> MediaItemType[]
+ finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy);
+ }
+
+ setMediaItems(finalMedia);
+ }, [feedPosts, customPictures, sortBy]);
+
+ // Scroll Restoration Logic
+ const cacheKey = `${navigationSource}-${navigationSourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`;
+ const hasRestoredScroll = useRef(false);
+
+ // Restore scroll when mediaItems are populated
+ useLayoutEffect(() => {
+ // Enable restoration if we have items, haven't restored yet, and either it's NOT custom pictures OR it IS a widget with an ID
+ const shouldRestore = mediaItems.length > 0 &&
+ !hasRestoredScroll.current &&
+ (!customPictures || (navigationSource === 'widget' && navigationSourceId));
+
+ if (shouldRestore) {
+ const cached = getCache(cacheKey);
+
+ if (cached && cached.scrollY > 0) {
+ window.scrollTo(0, cached.scrollY);
+ }
+ hasRestoredScroll.current = true;
+ }
+ }, [mediaItems, cacheKey, getCache, customPictures, navigationSource, navigationSourceId]);
+
+ // Reset restored flag when source changes
+ useEffect(() => {
+ hasRestoredScroll.current = false;
+ }, [cacheKey]);
+
+ // Track scroll position
+ const lastScrollY = useRef(window.scrollY);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ lastScrollY.current = window.scrollY;
+ };
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ // Save scroll position on unmount or before source change
+ useEffect(() => {
+ return () => {
+ // Save if not custom pictures OR if it is a widget with an ID
+ const shouldSave = !customPictures || (navigationSource === 'widget' && navigationSourceId);
+
+ if (shouldSave) {
+ const cached = getCache(cacheKey); // Get latest data from cache (posts etc)
+ // Use lastScrollY instead of window.scrollY because unmount might happen after a scroll-to-top by router
+
+ if (cached && lastScrollY.current > 0) {
+ saveCache(cacheKey, { ...cached, scrollY: lastScrollY.current });
+ }
+ }
+ };
+ }, [cacheKey, getCache, saveCache, customPictures, navigationSource, navigationSourceId]);
+
+ const fetchMediaFromPicturesTable = async () => {
+ // Manual Refresh Stub if needed - for onDelete/refresh actions
+ // Since the hook data is reactive, we might need a refresh function from the hook
+ // But for now, we can just reload the page or implement refresh in future.
+ window.location.reload();
+ };
+
+ const fetchUserLikes = async () => {
+ if (!user || mediaItems.length === 0) return;
+
+ try {
+ const { pictureLikes } = await fetchUserMediaLikes(user.id);
+
+ // Filter to only displayed items
+ const targetIds = new Set(
+ mediaItems.map(item => item.picture_id || item.id).filter(Boolean)
+ );
+
+ setUserLikes(prev => {
+ const newSet = new Set(prev);
+ pictureLikes.forEach(id => {
+ if (targetIds.has(id)) newSet.add(id);
+ });
+ return newSet;
+ });
+ } catch (error) {
+ console.error('Error fetching user likes:', error);
+ }
+ };
+
+ const handleMediaClick = (mediaId: string, type: MediaType, index: number) => {
+ const item = mediaItems.find(i => i.id === mediaId);
+
+ if (item?.meta?.url) {
+ navigate(item.meta.url);
+ return;
+ }
+
+ if (type === 'page-intern') {
+ if (item && item.meta?.slug) {
+ navigate(`/user/${item.author?.username || item.user_id}/pages/${item.meta.slug}`);
+ return;
+ }
+ }
+
+ navigate(`/post/${mediaId}`);
+ };
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (dragLeaveTimeoutRef.current) {
+ clearTimeout(dragLeaveTimeoutRef.current);
+ }
+ if (isOwner && onFilesDrop) setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (dragLeaveTimeoutRef.current) {
+ clearTimeout(dragLeaveTimeoutRef.current);
+ }
+ dragLeaveTimeoutRef.current = window.setTimeout(() => {
+ setIsDragging(false);
+ }, 100);
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ if (!isOwner || !onFilesDrop) return;
+
+ const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'));
+ if (files.length > 0) {
+ onFilesDrop(files);
+ }
+ };
+
+ const handleEditPost = async (postId: string) => {
+ try {
+ const toastId = toast.loading('Loading post for editing...');
+
+ // Fetch full post via API (returns FeedPost with pictures)
+ const post = await fetchPostById(postId);
+
+ if (!post) throw new Error('Post not found');
+
+ if (!post.pictures || post.pictures.length === 0) {
+ throw new Error('No pictures found in post');
+ }
+
+ // Transform pictures for wizard
+ const wizardImages = post.pictures
+ .sort((a: any, b: any) => ((a.position || 0) - (b.position || 0)))
+ .map((p: any) => ({
+ id: p.id,
+ path: p.id,
+ src: p.image_url,
+ title: p.title,
+ description: p.description || '',
+ selected: false,
+ realDatabaseId: p.id
+ }));
+
+ toast.dismiss(toastId);
+
+ navigate('/wizard', {
+ state: {
+ mode: 'post',
+ initialImages: wizardImages,
+ postTitle: post.title,
+ postDescription: post.description,
+ editingPostId: post.id
+ }
+ });
+ } catch (error) {
+ console.error('Error opening post editor:', error);
+ toast.error('Failed to open post editor');
+ }
+ };
+
+
+
+
+ // Group media items by category
+ // - When filtering by category: group by immediate subcategories (show parent + each child)
+ // - When on home feed: don't group (flat list)
+ const isSearchTabAllOrFiles = navigationSource === 'search' && (!contentType || contentType === 'files');
+ const shouldGroupByCategory = !!categorySlugs && categorySlugs.length > 0;
+
+ const getSearchGroup = (item: any): string => {
+ if (item.type === 'page-vfs-folder') return 'Folders';
+ if (item._searchSource === 'picture') return 'Pictures';
+ 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 groupedItems = React.useMemo(() => {
+ if (isSearchTabAllOrFiles) {
+ const groups = new Map();
+ mediaItems.forEach(item => {
+ const group = getSearchGroup(item);
+ if (!groups.has(group)) groups.set(group, []);
+ groups.get(group)!.push(item);
+ });
+
+ const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files'];
+ const sections = [];
+ for (const group of orderedGroups) {
+ if (groups.has(group)) {
+ sections.push({
+ key: group,
+ title: group,
+ items: groups.get(group)!
+ });
+ }
+ }
+
+ if (sections.length > 0) return { sections };
+ return { sections: [{ key: 'all', title: null, items: mediaItems }] };
+ }
+
+ if (!shouldGroupByCategory) {
+ return { sections: [{ key: 'all', title: null, items: mediaItems }] };
+ }
+
+ const grouped = new Map();
+ const parentCategoryItems: MediaItemType[] = [];
+
+ mediaItems.forEach(item => {
+ // Find corresponding feed post to get category_paths
+ const feedPost = feedPosts.find(p => p.id === item.id || p.cover?.id === item.id);
+
+ if (!feedPost?.category_paths || feedPost.category_paths.length === 0) {
+ return;
+ }
+
+ // Check if item has an exact match to the parent category (path length = 1)
+ const hasExactParentMatch = feedPost.category_paths.some((path: any[]) =>
+ path.length === 1 && categorySlugs.includes(path[0].slug)
+ );
+
+ if (hasExactParentMatch) {
+ // Item is directly in the parent category
+ parentCategoryItems.push(item);
+ return;
+ }
+
+ // Otherwise, find the most specific path (longest) that includes the filtered category
+ let mostSpecificPath: any[] | null = null;
+ let categoryIndex = -1;
+
+ for (const path of feedPost.category_paths) {
+ const idx = path.findIndex((cat: any) => categorySlugs.includes(cat.slug));
+ if (idx !== -1) {
+ if (!mostSpecificPath || path.length > mostSpecificPath.length) {
+ mostSpecificPath = path;
+ categoryIndex = idx;
+ }
+ }
+ }
+
+ if (!mostSpecificPath || categoryIndex === -1) return;
+
+ // Item is in a subcategory - use the immediate child category
+ const subcategory = mostSpecificPath[categoryIndex + 1];
+ if (subcategory) {
+ const key = subcategory.slug;
+
+ if (!grouped.has(key)) {
+ grouped.set(key, []);
+ }
+ grouped.get(key)!.push(item);
+ }
+ });
+
+ const sections = [];
+
+ // Add parent category section first (if it has items)
+ if (parentCategoryItems.length > 0) {
+ sections.push({
+ key: categorySlugs[0],
+ title: categorySlugs[0].replace(/-/g, ' '),
+ items: parentCategoryItems
+ });
+ }
+
+ // Add subcategory sections
+ Array.from(grouped.entries()).forEach(([key, items]) => {
+ sections.push({
+ key,
+ title: key.replace(/-/g, ' '),
+ items
+ });
+ });
+
+ // If no sections, return all items in one section
+ if (sections.length === 0) {
+ return { sections: [{ key: 'all', title: null, items: mediaItems }] };
+ }
+
+ return { sections };
+ }, [mediaItems, feedPosts, shouldGroupByCategory, categorySlugs, navigationSource, isSearchTabAllOrFiles]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ const hasItems = mediaItems.length > 0;
+ return (
+
+ {hasItems && isOwner && onFilesDrop && navigationSource === 'collection' && (
+
+
+
Drag and drop more images here to upload.
+
+ )}
+
+ {!hasItems ? (
+ isOwner && navigationSource === 'collection' && onFilesDrop ? (
+
+
+
+
This collection is empty
+
Drag and drop images here to get started.
+
+
+ ) : (
+
+
+
No media yet!
+
Be the first to share content with the community.
+
+
+ )
+ ) : (
+ <>
+ {groupedItems.sections.map((section) => (
+
+ {section.title && (
+
+
+ {section.title}
+
+ )}
+ {(() => {
+ const isAuto = columns === 'auto';
+ const colNum = Number(columns);
+ const gridColsClass = isAuto ? 'grid-cols-[repeat(auto-fit,minmax(250px,380px))] justify-center' :
+ colNum === 1 ? 'grid-cols-1' :
+ colNum === 2 ? 'grid-cols-1 md:grid-cols-2' :
+ colNum === 3 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' :
+ colNum === 5 ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' :
+ colNum === 6 ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6' :
+ 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
+ return (
+
+ {section.items.map((item, index) => {
+ const itemType = normalizeMediaType(item.type);
+ const isVideo = isVideoType(itemType);
+ // For images, convert URL to optimized format
+ const displayUrl = item.image_url;
+ if (isVideo) {
+ return (
+
+
handleMediaClick(item.id, itemType, index)}
+ onLike={fetchUserLikes}
+ onDelete={fetchMediaFromPicturesTable}
+ onEdit={handleEditPost}
+ created_at={item.created_at}
+ job={item.job}
+ responsive={item.responsive}
+ apiUrl={apiUrl}
+ preset={preset}
+ />
+ {/* TESTING: group-hover:opacity-100 disabled */}
+
+
+
+ );
+ }
+
+ return (
+
handleMediaClick(item.id, itemType, index)}
+ onLike={fetchUserLikes}
+ onDelete={fetchMediaFromPicturesTable}
+ onEdit={handleEditPost}
+
+ created_at={item.created_at}
+ job={item.job}
+ responsive={item.responsive}
+ apiUrl={apiUrl}
+ preset={preset}
+ />
+ );
+ })}
+
+ );
+ })()}
+
+ ))}
+ >
+ )}
+
+ {/* Loading Indicator / Observer Target */}
+
+ {isFetchingMore && (
+
+ )}
+
+
+ );
+};
+
+// Backward compatibility export
+export default MediaGrid;
+
+// Named exports for clarity
+export { MediaGrid };
+export const PhotoGrid = MediaGrid;
diff --git a/packages/ui/src/components/PromptForm.tsx b/packages/ui/src/components/PromptForm.tsx
index 96703fd5..22fa783b 100644
--- a/packages/ui/src/components/PromptForm.tsx
+++ b/packages/ui/src/components/PromptForm.tsx
@@ -1,533 +1,533 @@
-import React, { useState, useEffect } from 'react';
-import { ImageFile, PromptTemplate } from '../types';
-import { QUICK_ACTIONS } from '../constants';
-import ImageGallery from './ImageGallery';
-import { useDropZone } from '../hooks/useDropZone';
-import TemplateManager from './TemplateManager';
-import { tauriApi } from '../lib/tauriApi';
-import log from '../lib/log';
-import { Eraser, Sparkles, Crop, Palette, Package, FolderOpen, Plus, History, ChevronUp, ChevronDown } from 'lucide-react';
-import { T } from '../i18n';
-
-function arrayBufferToBase64(buffer: number[]) {
- let binary = '';
- const bytes = new Uint8Array(buffer);
- const len = bytes.byteLength;
- for (let i = 0; i < len; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return window.btoa(binary);
-}
-
-function getActionIcon(iconName: string) {
- const iconMap = {
- 'Eraser': Eraser,
- 'Sparkles': Sparkles,
- 'Crop': Crop,
- 'Palette': Palette,
- 'Package': Package
- };
- const IconComponent = iconMap[iconName as keyof typeof iconMap];
- return IconComponent ? : {iconName};
-}
-
-interface PromptFormProps {
- prompt: string;
- setPrompt: (prompt: string) => void;
- dst: string;
- setDst: (dst: string) => void;
- openSaveDialog: () => void;
- openFilePicker: () => void;
- files: ImageFile[];
- getSelectedImages: () => ImageFile[];
- clearAllFiles: () => void;
- handleImageSelection: (path: string, isMultiSelect: boolean) => void;
- removeFile: (path: string) => void;
- isGenerating: boolean;
- saveAndClose: () => void;
- submit: () => void;
- addImageFromUrl: (url: string) => void;
- onImageDelete?: (path: string) => void;
- onImageSaveAs?: (path: string) => void;
- addFiles: (paths: string[]) => void;
- currentIndex: number;
- setCurrentIndex: (index: number) => void;
- prompts: PromptTemplate[];
- setPrompts: (prompts: PromptTemplate[]) => void;
- savePrompts: (prompts: PromptTemplate[]) => void;
- importPrompts: () => void;
- exportPrompts: () => void;
- quickStyles: readonly string[];
- appendStyle: (style: string) => void;
- quickActions: typeof QUICK_ACTIONS;
- executeQuickAction: (action: { name: string; prompt: string; iconName: string }) => Promise;
- promptHistory: string[];
- historyIndex: number;
- navigateHistory: (direction: 'up' | 'down') => void;
- fileHistory: string[];
- showFileHistory: boolean;
- setShowFileHistory: (show: boolean) => void;
- openFileFromHistory: (filePath: string) => Promise;
- onFileHistoryCleanup: (validFiles: string[]) => Promise;
- onLightboxPromptSubmit: (prompt: string, imagePath: string) => Promise;
- errorMessage?: string | null;
- setErrorMessage?: (message: string | null) => void;
-}
-
-const PromptForm: React.FC = ({
- prompt,
- setPrompt,
- dst,
- setDst,
- openSaveDialog,
- openFilePicker,
- files,
- getSelectedImages,
- clearAllFiles,
- handleImageSelection,
- removeFile,
- isGenerating,
- saveAndClose,
- submit,
- addImageFromUrl,
- onImageDelete,
- onImageSaveAs,
- addFiles,
- currentIndex,
- setCurrentIndex,
- prompts,
- setPrompts,
- savePrompts,
- importPrompts,
- exportPrompts,
- quickStyles,
- appendStyle,
- quickActions,
- executeQuickAction,
- promptHistory,
- historyIndex,
- navigateHistory,
- fileHistory,
- showFileHistory,
- setShowFileHistory,
- openFileFromHistory,
- onFileHistoryCleanup,
- onLightboxPromptSubmit,
- errorMessage,
- setErrorMessage,
-}) => {
- const selectedCount = getSelectedImages().length;
- const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
- const [historyImages, setHistoryImages] = useState([]);
- const [historyCurrentIndex, setHistoryCurrentIndex] = useState(0);
-
- // Handle clipboard paste for images
- const handlePaste = async (e: React.ClipboardEvent) => {
- try {
- log.info('📋 Paste event detected, checking for images...');
-
- // Try to get images from clipboard using our stubbed API
- const result = await tauriApi.parseClipboardImages('png', '');
-
- if (result?.success && result.paths && result.paths.length > 0) {
- log.info(`📋 Found ${result.paths.length} image(s) in clipboard`, {
- paths: result.paths.map(p => p.split(/[/\\]/).pop())
- });
-
- // Add the clipboard images to the files
- addFiles(result.paths);
-
- // Prevent default paste behavior since we handled it
- e.preventDefault();
- } else if (result?.error) {
- log.warn('📋 No images found in clipboard or error occurred', { error: result.error });
- } else {
- log.debug('📋 No images in clipboard, allowing normal paste');
- }
- } catch (error) {
- log.error('📋 Failed to parse clipboard', { error: (error as Error).message });
- }
- };
-
- // Load images for file history when modal opens
- useEffect(() => {
- if (showFileHistory && fileHistory.length > 0) {
- const loadHistoryImages = async () => {
- const imageFiles: ImageFile[] = [];
- const validFiles: string[] = [];
-
- for (const filePath of fileHistory) {
- try {
- // First check if file exists, then read it
- log.debug(`Checking file: ${filePath}`);
- const buffer = await tauriApi.fs.readFile(filePath);
- // Use the same conversion method as main app
- const base64 = arrayBufferToBase64(Array.from(buffer));
- const ext = filePath.toLowerCase().split('.').pop();
- const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg';
- const src = `data:${mimeType};base64,${base64}`;
- imageFiles.push({
- path: filePath,
- src,
- isGenerated: true,
- selected: false
- });
- validFiles.push(filePath);
- } catch (error) {
- // File doesn't exist, skip it and log with full path
- log.warn(`File not found in history: ${filePath}`, {
- filename: filePath.split(/[/\\]/).pop(),
- fullPath: filePath,
- error: (error as Error).message
- });
- }
- }
-
- // If some files were invalid, update the fileHistory and store
- if (validFiles.length !== fileHistory.length) {
- log.info(`Cleaning up file history: ${fileHistory.length - validFiles.length} invalid files removed`);
- try {
- await onFileHistoryCleanup(validFiles);
- log.info('💾 File history cleaned and saved');
- } catch (cleanupError) {
- log.error('Failed to cleanup file history', { error: (cleanupError as Error).message });
- }
- }
-
- setHistoryImages(imageFiles);
- setHistoryCurrentIndex(0);
- };
- loadHistoryImages();
- } else if (showFileHistory) {
- // No file history, show empty state immediately
- setHistoryImages([]);
- }
- }, [showFileHistory, fileHistory]);
-
- return (
-
- );
-};
-
-export default PromptForm;
\ No newline at end of file
+import React, { useState, useEffect } from 'react';
+import { ImageFile, PromptTemplate } from '../types';
+import { QUICK_ACTIONS } from '../constants';
+import ImageGallery from './ImageGallery';
+import { useDropZone } from '../hooks/useDropZone';
+import TemplateManager from './TemplateManager';
+import { tauriApi } from '../lib/tauriApi';
+import log from '../lib/log';
+import { Eraser, Sparkles, Crop, Palette, Package, FolderOpen, Plus, History, ChevronUp, ChevronDown } from 'lucide-react';
+import { T } from '../i18n';
+
+function arrayBufferToBase64(buffer: number[]) {
+ let binary = '';
+ const bytes = new Uint8Array(buffer);
+ const len = bytes.byteLength;
+ for (let i = 0; i < len; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return window.btoa(binary);
+}
+
+function getActionIcon(iconName: string) {
+ const iconMap = {
+ 'Eraser': Eraser,
+ 'Sparkles': Sparkles,
+ 'Crop': Crop,
+ 'Palette': Palette,
+ 'Package': Package
+ };
+ const IconComponent = iconMap[iconName as keyof typeof iconMap];
+ return IconComponent ? : {iconName};
+}
+
+interface PromptFormProps {
+ prompt: string;
+ setPrompt: (prompt: string) => void;
+ dst: string;
+ setDst: (dst: string) => void;
+ openSaveDialog: () => void;
+ openFilePicker: () => void;
+ files: ImageFile[];
+ getSelectedImages: () => ImageFile[];
+ clearAllFiles: () => void;
+ handleImageSelection: (path: string, isMultiSelect: boolean) => void;
+ removeFile: (path: string) => void;
+ isGenerating: boolean;
+ saveAndClose: () => void;
+ submit: () => void;
+ addImageFromUrl: (url: string) => void;
+ onImageDelete?: (path: string) => void;
+ onImageSaveAs?: (path: string) => void;
+ addFiles: (paths: string[]) => void;
+ currentIndex: number;
+ setCurrentIndex: (index: number) => void;
+ prompts: PromptTemplate[];
+ setPrompts: (prompts: PromptTemplate[]) => void;
+ savePrompts: (prompts: PromptTemplate[]) => void;
+ importPrompts: () => void;
+ exportPrompts: () => void;
+ quickStyles: readonly string[];
+ appendStyle: (style: string) => void;
+ quickActions: typeof QUICK_ACTIONS;
+ executeQuickAction: (action: { name: string; prompt: string; iconName: string }) => Promise;
+ promptHistory: string[];
+ historyIndex: number;
+ navigateHistory: (direction: 'up' | 'down') => void;
+ fileHistory: string[];
+ showFileHistory: boolean;
+ setShowFileHistory: (show: boolean) => void;
+ openFileFromHistory: (filePath: string) => Promise;
+ onFileHistoryCleanup: (validFiles: string[]) => Promise;
+ onLightboxPromptSubmit: (prompt: string, imagePath: string) => Promise;
+ errorMessage?: string | null;
+ setErrorMessage?: (message: string | null) => void;
+}
+
+const PromptForm: React.FC = ({
+ prompt,
+ setPrompt,
+ dst,
+ setDst,
+ openSaveDialog,
+ openFilePicker,
+ files,
+ getSelectedImages,
+ clearAllFiles,
+ handleImageSelection,
+ removeFile,
+ isGenerating,
+ saveAndClose,
+ submit,
+ addImageFromUrl,
+ onImageDelete,
+ onImageSaveAs,
+ addFiles,
+ currentIndex,
+ setCurrentIndex,
+ prompts,
+ setPrompts,
+ savePrompts,
+ importPrompts,
+ exportPrompts,
+ quickStyles,
+ appendStyle,
+ quickActions,
+ executeQuickAction,
+ promptHistory,
+ historyIndex,
+ navigateHistory,
+ fileHistory,
+ showFileHistory,
+ setShowFileHistory,
+ openFileFromHistory,
+ onFileHistoryCleanup,
+ onLightboxPromptSubmit,
+ errorMessage,
+ setErrorMessage,
+}) => {
+ const selectedCount = getSelectedImages().length;
+ const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
+ const [historyImages, setHistoryImages] = useState([]);
+ const [historyCurrentIndex, setHistoryCurrentIndex] = useState(0);
+
+ // Handle clipboard paste for images
+ const handlePaste = async (e: React.ClipboardEvent) => {
+ try {
+ log.info('📋 Paste event detected, checking for images...');
+
+ // Try to get images from clipboard using our stubbed API
+ const result = await tauriApi.parseClipboardImages('png', '');
+
+ if (result?.success && result.paths && result.paths.length > 0) {
+ log.info(`📋 Found ${result.paths.length} image(s) in clipboard`, {
+ paths: result.paths.map(p => p.split(/[/\\]/).pop())
+ });
+
+ // Add the clipboard images to the files
+ addFiles(result.paths);
+
+ // Prevent default paste behavior since we handled it
+ e.preventDefault();
+ } else if (result?.error) {
+ log.warn('📋 No images found in clipboard or error occurred', { error: result.error });
+ } else {
+ log.debug('📋 No images in clipboard, allowing normal paste');
+ }
+ } catch (error) {
+ log.error('📋 Failed to parse clipboard', { error: (error as Error).message });
+ }
+ };
+
+ // Load images for file history when modal opens
+ useEffect(() => {
+ if (showFileHistory && fileHistory.length > 0) {
+ const loadHistoryImages = async () => {
+ const imageFiles: ImageFile[] = [];
+ const validFiles: string[] = [];
+
+ for (const filePath of fileHistory) {
+ try {
+ // First check if file exists, then read it
+ log.debug(`Checking file: ${filePath}`);
+ const buffer = await tauriApi.fs.readFile(filePath);
+ // Use the same conversion method as main app
+ const base64 = arrayBufferToBase64(Array.from(buffer));
+ const ext = filePath.toLowerCase().split('.').pop();
+ const mimeType = ext === 'png' ? 'image/png' : 'image/jpeg';
+ const src = `data:${mimeType};base64,${base64}`;
+ imageFiles.push({
+ path: filePath,
+ src,
+ isGenerated: true,
+ selected: false
+ });
+ validFiles.push(filePath);
+ } catch (error) {
+ // File doesn't exist, skip it and log with full path
+ log.warn(`File not found in history: ${filePath}`, {
+ filename: filePath.split(/[/\\]/).pop(),
+ fullPath: filePath,
+ error: (error as Error).message
+ });
+ }
+ }
+
+ // If some files were invalid, update the fileHistory and store
+ if (validFiles.length !== fileHistory.length) {
+ log.info(`Cleaning up file history: ${fileHistory.length - validFiles.length} invalid files removed`);
+ try {
+ await onFileHistoryCleanup(validFiles);
+ log.info('💾 File history cleaned and saved');
+ } catch (cleanupError) {
+ log.error('Failed to cleanup file history', { error: (cleanupError as Error).message });
+ }
+ }
+
+ setHistoryImages(imageFiles);
+ setHistoryCurrentIndex(0);
+ };
+ loadHistoryImages();
+ } else if (showFileHistory) {
+ // No file history, show empty state immediately
+ setHistoryImages([]);
+ }
+ }, [showFileHistory, fileHistory]);
+
+ return (
+
+ );
+};
+
+export default PromptForm;
diff --git a/packages/ui/src/components/PublishDialog.tsx b/packages/ui/src/components/PublishDialog.tsx
index b6ef724e..0bad1bb1 100644
--- a/packages/ui/src/components/PublishDialog.tsx
+++ b/packages/ui/src/components/PublishDialog.tsx
@@ -1,282 +1,282 @@
-import { useState, useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Label } from '@/components/ui/label';
-import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Upload, RefreshCw, GitBranch, Bookmark } from 'lucide-react';
-import { supabase } from '@/integrations/supabase/client';
-import { useAuth } from '@/hooks/useAuth';
-
-interface Collection {
- id: string;
- name: string;
- slug: string;
-}
-
-interface PublishDialogProps {
- isOpen: boolean;
- onClose: () => void;
- onPublish: (option: 'overwrite' | 'new' | 'version' | 'add-to-post', title?: string, description?: string, parentId?: string, collectionIds?: string[]) => void;
- originalTitle: string;
- originalImageId?: string;
- isPublishing?: boolean;
- editingPostId?: string;
-}
-
-export default function PublishDialog({
- isOpen,
- onClose,
- onPublish,
- originalTitle,
- originalImageId,
- isPublishing = false,
- editingPostId
-}: PublishDialogProps) {
- const { user } = useAuth();
- const [publishOption, setPublishOption] = useState<'overwrite' | 'new' | 'version' | 'add-to-post'>('new');
- const [title, setTitle] = useState(originalTitle);
- const [description, setDescription] = useState('');
- const [collections, setCollections] = useState([]);
- const [selectedCollections, setSelectedCollections] = useState([]);
- const [loadingCollections, setLoadingCollections] = useState(false);
-
- // Load user's collections when dialog opens
- useEffect(() => {
- if (isOpen && user) {
- loadCollections();
- // Default to "add-to-post" if editing a post
- if (editingPostId) {
- setPublishOption('add-to-post');
- } else {
- setPublishOption('new');
- }
- }
- }, [isOpen, user, editingPostId]);
-
- const loadCollections = async () => {
- if (!user) return;
-
- setLoadingCollections(true);
- try {
- const { data, error } = await supabase
- .from('collections')
- .select('id, name, slug')
- .eq('user_id', user.id)
- .order('name');
-
- if (error) throw error;
- setCollections(data || []);
- } catch (error) {
- console.error('Error loading collections:', error);
- } finally {
- setLoadingCollections(false);
- }
- };
-
- const toggleCollection = (collectionId: string) => {
- setSelectedCollections(prev =>
- prev.includes(collectionId)
- ? prev.filter(id => id !== collectionId)
- : [...prev, collectionId]
- );
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- onPublish(
- publishOption,
- title.trim() || undefined,
- description.trim() || undefined,
- originalImageId,
- selectedCollections.length > 0 ? selectedCollections : undefined
- );
- };
-
- const handleClose = () => {
- if (!isPublishing) {
- onClose();
- }
- };
-
- if (!isOpen) return null;
-
- return (
-
-
-
-
- Publish Generated Image
-
-
-
-
-
-
- );
-}
\ No newline at end of file
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Upload, RefreshCw, GitBranch, Bookmark } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/hooks/useAuth';
+
+interface Collection {
+ id: string;
+ name: string;
+ slug: string;
+}
+
+interface PublishDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onPublish: (option: 'overwrite' | 'new' | 'version' | 'add-to-post', title?: string, description?: string, parentId?: string, collectionIds?: string[]) => void;
+ originalTitle: string;
+ originalImageId?: string;
+ isPublishing?: boolean;
+ editingPostId?: string;
+}
+
+export default function PublishDialog({
+ isOpen,
+ onClose,
+ onPublish,
+ originalTitle,
+ originalImageId,
+ isPublishing = false,
+ editingPostId
+}: PublishDialogProps) {
+ const { user } = useAuth();
+ const [publishOption, setPublishOption] = useState<'overwrite' | 'new' | 'version' | 'add-to-post'>('new');
+ const [title, setTitle] = useState(originalTitle);
+ const [description, setDescription] = useState('');
+ const [collections, setCollections] = useState([]);
+ const [selectedCollections, setSelectedCollections] = useState([]);
+ const [loadingCollections, setLoadingCollections] = useState(false);
+
+ // Load user's collections when dialog opens
+ useEffect(() => {
+ if (isOpen && user) {
+ loadCollections();
+ // Default to "add-to-post" if editing a post
+ if (editingPostId) {
+ setPublishOption('add-to-post');
+ } else {
+ setPublishOption('new');
+ }
+ }
+ }, [isOpen, user, editingPostId]);
+
+ const loadCollections = async () => {
+ if (!user) return;
+
+ setLoadingCollections(true);
+ try {
+ const { data, error } = await supabase
+ .from('collections')
+ .select('id, name, slug')
+ .eq('user_id', user.id)
+ .order('name');
+
+ if (error) throw error;
+ setCollections(data || []);
+ } catch (error) {
+ console.error('Error loading collections:', error);
+ } finally {
+ setLoadingCollections(false);
+ }
+ };
+
+ const toggleCollection = (collectionId: string) => {
+ setSelectedCollections(prev =>
+ prev.includes(collectionId)
+ ? prev.filter(id => id !== collectionId)
+ : [...prev, collectionId]
+ );
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onPublish(
+ publishOption,
+ title.trim() || undefined,
+ description.trim() || undefined,
+ originalImageId,
+ selectedCollections.length > 0 ? selectedCollections : undefined
+ );
+ };
+
+ const handleClose = () => {
+ if (!isPublishing) {
+ onClose();
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+ Publish Generated Image
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/TemplateManager.tsx b/packages/ui/src/components/TemplateManager.tsx
index ff6a6ba6..c98b6bc3 100644
--- a/packages/ui/src/components/TemplateManager.tsx
+++ b/packages/ui/src/components/TemplateManager.tsx
@@ -1,93 +1,93 @@
-import React from 'react';
-import { PromptTemplate } from '../types';
-import { Save, Download, Upload } from 'lucide-react';
-
-interface TemplateManagerProps {
- prompts: PromptTemplate[];
- currentPrompt: string;
- onSelectPrompt: (prompt: string) => void;
- onSavePrompt: (name: string, text: string) => void;
- onImportPrompts: () => void;
- onExportPrompts: () => void;
-}
-
-const TemplateManager: React.FC = ({
- prompts,
- currentPrompt,
- onSelectPrompt,
- onSavePrompt,
- onImportPrompts,
- onExportPrompts,
-}) => {
- const handleSaveTemplate = () => {
- if (!currentPrompt.trim()) return;
-
- const name = prompt('Enter template name:');
- if (name && name.trim()) {
- onSavePrompt(name.trim(), currentPrompt);
- }
- };
-
- return (
-
-
- {/* Left: Template Picker */}
-
-
Templates
-
- {prompts.length === 0 ? (
- No templates saved yet
- ) : (
- prompts.map((template) => (
-
- ))
- )}
-
-
-
- {/* Right: Template Management Icons */}
-
-
Manage
-
-
-
-
-
-
-
-
- );
-};
-
-export default TemplateManager;
\ No newline at end of file
+import React from 'react';
+import { PromptTemplate } from '../types';
+import { Save, Download, Upload } from 'lucide-react';
+
+interface TemplateManagerProps {
+ prompts: PromptTemplate[];
+ currentPrompt: string;
+ onSelectPrompt: (prompt: string) => void;
+ onSavePrompt: (name: string, text: string) => void;
+ onImportPrompts: () => void;
+ onExportPrompts: () => void;
+}
+
+const TemplateManager: React.FC = ({
+ prompts,
+ currentPrompt,
+ onSelectPrompt,
+ onSavePrompt,
+ onImportPrompts,
+ onExportPrompts,
+}) => {
+ const handleSaveTemplate = () => {
+ if (!currentPrompt.trim()) return;
+
+ const name = prompt('Enter template name:');
+ if (name && name.trim()) {
+ onSavePrompt(name.trim(), currentPrompt);
+ }
+ };
+
+ return (
+
+
+ {/* Left: Template Picker */}
+
+
Templates
+
+ {prompts.length === 0 ? (
+ No templates saved yet
+ ) : (
+ prompts.map((template) => (
+
+ ))
+ )}
+
+
+
+ {/* Right: Template Management Icons */}
+
+
Manage
+
+
+
+
+
+
+
+
+ );
+};
+
+export default TemplateManager;
diff --git a/packages/ui/src/components/ThemeProvider.tsx b/packages/ui/src/components/ThemeProvider.tsx
index 649c2a77..0eaf2b44 100644
--- a/packages/ui/src/components/ThemeProvider.tsx
+++ b/packages/ui/src/components/ThemeProvider.tsx
@@ -1,76 +1,76 @@
-import { createContext, useContext, useEffect, useState } from "react";
-
-type Theme = "dark" | "light" | "system";
-
-type ThemeProviderProps = {
- children: React.ReactNode;
- defaultTheme?: Theme;
- storageKey?: string;
-};
-
-type ThemeProviderState = {
- theme: Theme;
- setTheme: (theme: Theme) => void;
-};
-
-const initialState: ThemeProviderState = {
- theme: "system",
- setTheme: () => null,
-};
-
-const ThemeProviderContext = createContext(initialState);
-
-export function ThemeProvider({
- children,
- defaultTheme = "system",
- storageKey = "ui-theme",
- ...props
-}: ThemeProviderProps) {
- const [theme, setTheme] = useState(
- () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
- );
-
- useEffect(() => {
- const root = window.document.documentElement;
-
- if (theme === "system") {
- const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
-
- const applyTheme = () => {
- root.classList.remove("light", "dark");
- root.classList.add(mediaQuery.matches ? "dark" : "light");
- };
-
- applyTheme();
-
- mediaQuery.addEventListener("change", applyTheme);
- return () => mediaQuery.removeEventListener("change", applyTheme);
- }
-
- root.classList.remove("light", "dark");
- root.classList.add(theme);
- }, [theme]);
-
- const value = {
- theme,
- setTheme: (theme: Theme) => {
- localStorage.setItem(storageKey, theme);
- setTheme(theme);
- },
- };
-
- return (
-
- {children}
-
- );
-}
-
-export const useTheme = () => {
- const context = useContext(ThemeProviderContext);
-
- if (context === undefined)
- throw new Error("useTheme must be used within a ThemeProvider");
-
- return context;
-};
\ No newline at end of file
+import { createContext, useContext, useEffect, useState } from "react";
+
+type Theme = "dark" | "light" | "system";
+
+type ThemeProviderProps = {
+ children: React.ReactNode;
+ defaultTheme?: Theme;
+ storageKey?: string;
+};
+
+type ThemeProviderState = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+};
+
+const initialState: ThemeProviderState = {
+ theme: "system",
+ setTheme: () => null,
+};
+
+const ThemeProviderContext = createContext(initialState);
+
+export function ThemeProvider({
+ children,
+ defaultTheme = "system",
+ storageKey = "ui-theme",
+ ...props
+}: ThemeProviderProps) {
+ const [theme, setTheme] = useState(
+ () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
+ );
+
+ useEffect(() => {
+ const root = window.document.documentElement;
+
+ if (theme === "system") {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+
+ const applyTheme = () => {
+ root.classList.remove("light", "dark");
+ root.classList.add(mediaQuery.matches ? "dark" : "light");
+ };
+
+ applyTheme();
+
+ mediaQuery.addEventListener("change", applyTheme);
+ return () => mediaQuery.removeEventListener("change", applyTheme);
+ }
+
+ root.classList.remove("light", "dark");
+ root.classList.add(theme);
+ }, [theme]);
+
+ const value = {
+ theme,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(storageKey, theme);
+ setTheme(theme);
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useTheme = () => {
+ const context = useContext(ThemeProviderContext);
+
+ if (context === undefined)
+ throw new Error("useTheme must be used within a ThemeProvider");
+
+ return context;
+};
diff --git a/packages/ui/src/components/ThemeToggle.tsx b/packages/ui/src/components/ThemeToggle.tsx
index 6b9ab5c4..e3f1fd80 100644
--- a/packages/ui/src/components/ThemeToggle.tsx
+++ b/packages/ui/src/components/ThemeToggle.tsx
@@ -1,39 +1,39 @@
-import { Monitor, Moon, Sun } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { useTheme } from "@/components/ThemeProvider";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-
-export function ThemeToggle() {
- const { setTheme } = useTheme();
-
- return (
-
-
-
-
-
- setTheme("light")}>
-
- Light
-
- setTheme("dark")}>
-
- Dark
-
- setTheme("system")}>
-
- System
-
-
-
- );
-}
\ No newline at end of file
+import { Monitor, Moon, Sun } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useTheme } from "@/components/ThemeProvider";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function ThemeToggle() {
+ const { setTheme } = useTheme();
+
+ return (
+
+
+
+
+
+ setTheme("light")}>
+
+ Light
+
+ setTheme("dark")}>
+
+ Dark
+
+ setTheme("system")}>
+
+ System
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/TopNavigation.tsx b/packages/ui/src/components/TopNavigation.tsx
index dfc150c4..716faab0 100644
--- a/packages/ui/src/components/TopNavigation.tsx
+++ b/packages/ui/src/components/TopNavigation.tsx
@@ -1,339 +1,339 @@
-import { Link, useLocation } from "react-router-dom";
-import { useAuth } from "@/hooks/useAuth";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { Home, User, Upload, LogOut, LogIn, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity, ShoppingCart, Pencil, Settings, MessageSquare } from "lucide-react";
-import { ThemeToggle } from "@/components/ThemeToggle";
-import { useWizardContext } from "@/hooks/useWizardContext";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { useNavigate } from "react-router-dom";
-import { useProfiles } from "@/contexts/ProfilesContext";
-import { useState, useRef, useEffect, lazy, Suspense } from "react";
-import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n";
-const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup })));
-
-const TopNavigation = () => {
- const { user, signOut, roles } = useAuth();
- const { fetchProfile, profiles } = useProfiles();
- const location = useLocation();
- const navigate = useNavigate();
- const [searchQuery, setSearchQuery] = useState('');
- const searchInputRef = useRef(null);
- const currentLang = getCurrentLang();
- const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
-
- // Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load
- const [cartItemCount, setCartItemCount] = useState(0);
- useEffect(() => {
- let unsubscribe: (() => void) | undefined;
- import("@polymech/ecommerce").then(({ useCartStore }) => {
- // Read initial value
- setCartItemCount(useCartStore.getState().itemCount);
- // Subscribe to changes
- unsubscribe = useCartStore.subscribe((state) => {
- setCartItemCount(state.itemCount);
- });
- }).catch(() => { /* ecommerce not available */ });
- return () => unsubscribe?.();
- }, []);
-
- const authPath = '/auth';
-
- useEffect(() => {
- if (user?.id) {
- fetchProfile(user.id);
- }
- }, [user?.id, fetchProfile]);
-
- const userProfile = user ? profiles[user.id] : null;
- const username = userProfile?.user_id || user?.id;
- const isActive = (path: string) => location.pathname === path;
-
- {/* Profile Grid Button - Direct to profile feed */ }
- {
- user && (
-
- )
- }
-
- // ...
-
-
-
-
- Profile
-
-
-
- const handleLanguageChange = (langCode: string) => {
- setLanguage(langCode as any);
- };
-
- const handleSignOut = async () => {
- await signOut();
- };
-
- const handleWizardOpen = () => {
- setCreationWizardOpen(true);
- };
-
- const handleSearchSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (searchQuery.trim()) {
- navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
- searchInputRef.current?.blur();
- }
- };
-
- const handleSearchKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Escape') {
- setSearchQuery('');
- searchInputRef.current?.blur();
- }
- };
-
- return (
-
-
- {/* Logo / Brand */}
-
-

-
PolyMech
-
-
- {/* Search Bar - Center */}
-
-
-
-
- {/* Center Navigation - Desktop only, moved after search */}
-
-
- {/* Right Side Actions */}
-
- {/* Support Chat - public */}
-
-
- {/* Mobile Search Button */}
-
-
- {/* Magic Button - AI Image Generator */}
- {user && (
-
- )}
-
- {/* Profile Grid Button - Direct to profile feed */}
- {user && (
-
- )}
-
- {/* Cart Icon with Badge */}
- {cartItemCount > 0 && (
-
- )}
-
- {/* Language Selector */}
-
-
-
-
-
- {supportedLanguages.map((lang) => (
- handleLanguageChange(lang.code)}
- className={currentLang === lang.code ? 'bg-accent' : ''}
- >
- {lang.name}
-
- ))}
-
-
-
-
-
- {user ? (
-
-
-
-
-
-
-
-
User {user.id.slice(0, 8)}
-
- {user.email}
-
-
-
-
-
-
-
- Profile
-
-
-
-
-
- Edit Profile Page
-
-
-
-
-
- Settings
-
-
-
- {roles.includes("admin") && (
- <>
-
-
-
- Admin
-
-
-
-
-
- Edit Home Page
-
-
-
-
-
- I18n
-
-
-
-
-
- Chat
-
-
- >
- )}
-
-
-
- Upload
-
-
-
-
-
- Sign out
-
-
-
- ) : (
-
- )}
-
-
-
- {user && creationWizardOpen && (
-
- setCreationWizardOpen(false)}
- preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
- initialMode={creationWizardMode}
- />
-
- )}
-
- );
-};
-
-export default TopNavigation;
\ No newline at end of file
+import { Link, useLocation } from "react-router-dom";
+import { useAuth } from "@/hooks/useAuth";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Home, User, Upload, LogOut, LogIn, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity, ShoppingCart, Pencil, Settings, MessageSquare } from "lucide-react";
+import { ThemeToggle } from "@/components/ThemeToggle";
+import { useWizardContext } from "@/hooks/useWizardContext";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useNavigate } from "react-router-dom";
+import { useProfiles } from "@/contexts/ProfilesContext";
+import { useState, useRef, useEffect, lazy, Suspense } from "react";
+import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n";
+const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup })));
+
+const TopNavigation = () => {
+ const { user, signOut, roles } = useAuth();
+ const { fetchProfile, profiles } = useProfiles();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [searchQuery, setSearchQuery] = useState('');
+ const searchInputRef = useRef(null);
+ const currentLang = getCurrentLang();
+ const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
+
+ // Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load
+ const [cartItemCount, setCartItemCount] = useState(0);
+ useEffect(() => {
+ let unsubscribe: (() => void) | undefined;
+ import("@polymech/ecommerce").then(({ useCartStore }) => {
+ // Read initial value
+ setCartItemCount(useCartStore.getState().itemCount);
+ // Subscribe to changes
+ unsubscribe = useCartStore.subscribe((state) => {
+ setCartItemCount(state.itemCount);
+ });
+ }).catch(() => { /* ecommerce not available */ });
+ return () => unsubscribe?.();
+ }, []);
+
+ const authPath = '/auth';
+
+ useEffect(() => {
+ if (user?.id) {
+ fetchProfile(user.id);
+ }
+ }, [user?.id, fetchProfile]);
+
+ const userProfile = user ? profiles[user.id] : null;
+ const username = userProfile?.user_id || user?.id;
+ const isActive = (path: string) => location.pathname === path;
+
+ {/* Profile Grid Button - Direct to profile feed */ }
+ {
+ user && (
+
+ )
+ }
+
+ // ...
+
+
+
+
+ Profile
+
+
+
+ const handleLanguageChange = (langCode: string) => {
+ setLanguage(langCode as any);
+ };
+
+ const handleSignOut = async () => {
+ await signOut();
+ };
+
+ const handleWizardOpen = () => {
+ setCreationWizardOpen(true);
+ };
+
+ const handleSearchSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (searchQuery.trim()) {
+ navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
+ searchInputRef.current?.blur();
+ }
+ };
+
+ const handleSearchKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setSearchQuery('');
+ searchInputRef.current?.blur();
+ }
+ };
+
+ return (
+
+
+ {/* Logo / Brand */}
+
+

+
PolyMech
+
+
+ {/* Search Bar - Center */}
+
+
+
+
+ {/* Center Navigation - Desktop only, moved after search */}
+
+
+ {/* Right Side Actions */}
+
+ {/* Support Chat - public */}
+
+
+ {/* Mobile Search Button */}
+
+
+ {/* Magic Button - AI Image Generator */}
+ {user && (
+
+ )}
+
+ {/* Profile Grid Button - Direct to profile feed */}
+ {user && (
+
+ )}
+
+ {/* Cart Icon with Badge */}
+ {cartItemCount > 0 && (
+
+ )}
+
+ {/* Language Selector */}
+
+
+
+
+
+ {supportedLanguages.map((lang) => (
+ handleLanguageChange(lang.code)}
+ className={currentLang === lang.code ? 'bg-accent' : ''}
+ >
+ {lang.name}
+
+ ))}
+
+
+
+
+
+ {user ? (
+
+
+
+
+
+
+
+
User {user.id.slice(0, 8)}
+
+ {user.email}
+
+
+
+
+
+
+
+ Profile
+
+
+
+
+
+ Edit Profile Page
+
+
+
+
+
+ Settings
+
+
+
+ {roles.includes("admin") && (
+ <>
+
+
+
+ Admin
+
+
+
+
+
+ Edit Home Page
+
+
+
+
+
+ I18n
+
+
+
+
+
+ Chat
+
+
+ >
+ )}
+
+
+
+ Upload
+
+
+
+
+
+ Sign out
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {user && creationWizardOpen && (
+
+ setCreationWizardOpen(false)}
+ preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
+ initialMode={creationWizardMode}
+ />
+
+ )}
+
+ );
+};
+
+export default TopNavigation;
diff --git a/packages/ui/src/components/VersionSelector.tsx b/packages/ui/src/components/VersionSelector.tsx
index 02396397..122ebe3c 100644
--- a/packages/ui/src/components/VersionSelector.tsx
+++ b/packages/ui/src/components/VersionSelector.tsx
@@ -1,337 +1,337 @@
-import React, { useState, useEffect } from 'react';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { Check, Image as ImageIcon, Eye, EyeOff, Trash2 } from 'lucide-react';
-import { fetchPictureById, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures';
-import { useAuth } from '@/hooks/useAuth';
-import { toast } from 'sonner';
-import { T, translate } from '@/i18n';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-
-interface Version {
- id: string;
- title: string;
- image_url: string;
- is_selected: boolean;
- created_at: string;
- parent_id: string | null;
- visible: boolean;
- user_id: string; // Added for storage deletion path
-}
-
-interface VersionSelectorProps {
- currentPictureId: string;
- onVersionSelect: (selectedVersionId: string) => void;
-}
-
-const VersionSelector: React.FC = ({
- currentPictureId,
- onVersionSelect
-}) => {
- const { user } = useAuth();
- const [versions, setVersions] = useState([]);
- const [loading, setLoading] = useState(true);
- const [updating, setUpdating] = useState(null);
- const [toggling, setToggling] = useState(null);
- const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- const [versionToDelete, setVersionToDelete] = useState(null);
- const [isDeleting, setIsDeleting] = useState(false);
-
- useEffect(() => {
- loadVersions();
- }, [currentPictureId]);
-
- const loadVersions = async () => {
- if (!user || !currentPictureId) return;
-
- setLoading(true);
- try {
- // Get the current picture to determine if it's a parent or child
- const currentPicture = await fetchPictureById(currentPictureId);
- if (!currentPicture) throw new Error('Picture not found');
-
- // Fetch all user pictures and walk the full tree (same as VersionMap)
- const allPictures = await fetchUserPictures(currentPicture.user_id);
- if (!allPictures || allPictures.length === 0) {
- setVersions([]);
- return;
- }
-
- const pictureMap = new Map(allPictures.map((p: any) => [p.id, p]));
-
- // Walk up to find the true root
- let trueRootId = currentPicture.id;
- let current: any = pictureMap.get(currentPicture.id);
- while (current?.parent_id && pictureMap.has(current.parent_id)) {
- trueRootId = current.parent_id;
- current = pictureMap.get(current.parent_id);
- }
-
- // Collect all descendants from root
- const tree: any[] = [];
- const visited = new Set();
- const collect = (nodeId: string) => {
- if (visited.has(nodeId)) return;
- const node = pictureMap.get(nodeId);
- if (!node) return;
- visited.add(nodeId);
- tree.push(node);
- allPictures.filter((p: any) => p.parent_id === nodeId).forEach((child: any) => collect(child.id));
- };
- collect(trueRootId);
-
- setVersions(tree || []);
- } catch (error) {
- console.error('Error loading versions:', error);
- toast.error(translate('Failed to load image versions'));
- } finally {
- setLoading(false);
- }
- };
-
- const handleVersionSelect = async (versionId: string) => {
- if (!user) return;
-
- setUpdating(versionId);
- try {
- const targetVersion = versions.find(v => v.id === versionId);
- if (!targetVersion) return;
-
- // Toggle: if already selected, unselect; otherwise select
- const newSelected = !targetVersion.is_selected;
-
- await updatePicture(versionId, { is_selected: newSelected } as any);
-
- // Update local state
- setVersions(prevVersions =>
- prevVersions.map(v => ({
- ...v,
- is_selected: v.id === versionId ? newSelected : v.is_selected
- }))
- );
-
- toast.success(translate(newSelected ? 'Version selected!' : 'Version unselected!'));
- onVersionSelect(versionId);
- } catch (error) {
- console.error('Error toggling version selection:', error);
- toast.error(translate('Failed to update version'));
- } finally {
- setUpdating(null);
- }
- };
-
- const handleToggleVisibility = async (versionId: string, currentVisibility: boolean) => {
- if (!user) return;
-
- setToggling(versionId);
- try {
- await updatePicture(versionId, { visible: !currentVisibility } as any);
-
- // Update local state
- setVersions(prevVersions =>
- prevVersions.map(v => ({
- ...v,
- visible: v.id === versionId ? !currentVisibility : v.visible
- }))
- );
-
- toast.success(translate(!currentVisibility ? 'Version made visible successfully!' : 'Version hidden successfully!'));
- } catch (error) {
- console.error('Error toggling visibility:', error);
- toast.error(translate('Failed to update visibility'));
- } finally {
- setToggling(null);
- }
- };
-
- const handleDeleteClick = (version: Version) => {
- setVersionToDelete(version);
- setShowDeleteDialog(true);
- };
-
- const confirmDelete = async () => {
- if (!versionToDelete || !user) return;
-
- setIsDeleting(true);
- try {
- // 1. Find all descendants to delete (cascade)
- const allUserPictures = await fetchUserPictures(user.id);
-
- const findDescendants = (parentId: string): any[] => {
- const descendants: any[] = [];
- const children = allUserPictures.filter((p: any) => p.parent_id === parentId);
- children.forEach((child: any) => {
- descendants.push(child);
- descendants.push(...findDescendants(child.id));
- });
- return descendants;
- };
-
- const descendantsToDelete = findDescendants(versionToDelete.id);
- const allToDelete = [versionToDelete, ...descendantsToDelete];
- const idsToDelete = allToDelete.map(v => v.id);
-
- // 2. Batch delete via API (handles storage + db)
- await deletePictures(idsToDelete);
-
- // 3. Update local state
- const deletedIds = new Set(idsToDelete);
- setVersions(prev => prev.filter(v => !deletedIds.has(v.id)));
-
- const totalDeleted = allToDelete.length;
- toast.success(translate(`Deleted ${totalDeleted > 1 ? `${totalDeleted} versions` : 'version'} successfully`));
-
- } catch (error) {
- console.error('Error deleting version:', error);
- toast.error(translate('Failed to delete version'));
- } finally {
- setIsDeleting(false);
- setShowDeleteDialog(false);
- setVersionToDelete(null);
- }
- };
-
- if (loading) {
- return (
-
- );
- }
-
- if (versions.length <= 1) {
- return (
-
-
No other versions available for this image.
-
- );
- }
-
- return (
-
-
-
-
Image Versions
- {versions.length}
-
-
-
- {versions.map((version) => (
-
-
-
-

- {version.is_selected && (
-
-
-
- )}
-
-
-
-
{version.title}
-
- {new Date(version.created_at).toLocaleDateString()}
-
-
- {version.parent_id === null && (
- Original
- )}
- {!version.visible && (
- Hidden
- )}
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
-
-
- Delete Version
-
- Are you sure you want to delete this version? "{versionToDelete?.title}"
-
-
- This action cannot be undone.
-
-
-
-
- Cancel
- {
- e.preventDefault();
- confirmDelete();
- }}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- disabled={isDeleting}
- >
- {isDeleting ? Deleting... : Delete}
-
-
-
-
-
- );
-};
-
-export default VersionSelector;
\ No newline at end of file
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Check, Image as ImageIcon, Eye, EyeOff, Trash2 } from 'lucide-react';
+import { fetchPictureById, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures';
+import { useAuth } from '@/hooks/useAuth';
+import { toast } from 'sonner';
+import { T, translate } from '@/i18n';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+interface Version {
+ id: string;
+ title: string;
+ image_url: string;
+ is_selected: boolean;
+ created_at: string;
+ parent_id: string | null;
+ visible: boolean;
+ user_id: string; // Added for storage deletion path
+}
+
+interface VersionSelectorProps {
+ currentPictureId: string;
+ onVersionSelect: (selectedVersionId: string) => void;
+}
+
+const VersionSelector: React.FC = ({
+ currentPictureId,
+ onVersionSelect
+}) => {
+ const { user } = useAuth();
+ const [versions, setVersions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [updating, setUpdating] = useState(null);
+ const [toggling, setToggling] = useState(null);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [versionToDelete, setVersionToDelete] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ useEffect(() => {
+ loadVersions();
+ }, [currentPictureId]);
+
+ const loadVersions = async () => {
+ if (!user || !currentPictureId) return;
+
+ setLoading(true);
+ try {
+ // Get the current picture to determine if it's a parent or child
+ const currentPicture = await fetchPictureById(currentPictureId);
+ if (!currentPicture) throw new Error('Picture not found');
+
+ // Fetch all user pictures and walk the full tree (same as VersionMap)
+ const allPictures = await fetchUserPictures(currentPicture.user_id);
+ if (!allPictures || allPictures.length === 0) {
+ setVersions([]);
+ return;
+ }
+
+ const pictureMap = new Map(allPictures.map((p: any) => [p.id, p]));
+
+ // Walk up to find the true root
+ let trueRootId = currentPicture.id;
+ let current: any = pictureMap.get(currentPicture.id);
+ while (current?.parent_id && pictureMap.has(current.parent_id)) {
+ trueRootId = current.parent_id;
+ current = pictureMap.get(current.parent_id);
+ }
+
+ // Collect all descendants from root
+ const tree: any[] = [];
+ const visited = new Set();
+ const collect = (nodeId: string) => {
+ if (visited.has(nodeId)) return;
+ const node = pictureMap.get(nodeId);
+ if (!node) return;
+ visited.add(nodeId);
+ tree.push(node);
+ allPictures.filter((p: any) => p.parent_id === nodeId).forEach((child: any) => collect(child.id));
+ };
+ collect(trueRootId);
+
+ setVersions(tree || []);
+ } catch (error) {
+ console.error('Error loading versions:', error);
+ toast.error(translate('Failed to load image versions'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVersionSelect = async (versionId: string) => {
+ if (!user) return;
+
+ setUpdating(versionId);
+ try {
+ const targetVersion = versions.find(v => v.id === versionId);
+ if (!targetVersion) return;
+
+ // Toggle: if already selected, unselect; otherwise select
+ const newSelected = !targetVersion.is_selected;
+
+ await updatePicture(versionId, { is_selected: newSelected } as any);
+
+ // Update local state
+ setVersions(prevVersions =>
+ prevVersions.map(v => ({
+ ...v,
+ is_selected: v.id === versionId ? newSelected : v.is_selected
+ }))
+ );
+
+ toast.success(translate(newSelected ? 'Version selected!' : 'Version unselected!'));
+ onVersionSelect(versionId);
+ } catch (error) {
+ console.error('Error toggling version selection:', error);
+ toast.error(translate('Failed to update version'));
+ } finally {
+ setUpdating(null);
+ }
+ };
+
+ const handleToggleVisibility = async (versionId: string, currentVisibility: boolean) => {
+ if (!user) return;
+
+ setToggling(versionId);
+ try {
+ await updatePicture(versionId, { visible: !currentVisibility } as any);
+
+ // Update local state
+ setVersions(prevVersions =>
+ prevVersions.map(v => ({
+ ...v,
+ visible: v.id === versionId ? !currentVisibility : v.visible
+ }))
+ );
+
+ toast.success(translate(!currentVisibility ? 'Version made visible successfully!' : 'Version hidden successfully!'));
+ } catch (error) {
+ console.error('Error toggling visibility:', error);
+ toast.error(translate('Failed to update visibility'));
+ } finally {
+ setToggling(null);
+ }
+ };
+
+ const handleDeleteClick = (version: Version) => {
+ setVersionToDelete(version);
+ setShowDeleteDialog(true);
+ };
+
+ const confirmDelete = async () => {
+ if (!versionToDelete || !user) return;
+
+ setIsDeleting(true);
+ try {
+ // 1. Find all descendants to delete (cascade)
+ const allUserPictures = await fetchUserPictures(user.id);
+
+ const findDescendants = (parentId: string): any[] => {
+ const descendants: any[] = [];
+ const children = allUserPictures.filter((p: any) => p.parent_id === parentId);
+ children.forEach((child: any) => {
+ descendants.push(child);
+ descendants.push(...findDescendants(child.id));
+ });
+ return descendants;
+ };
+
+ const descendantsToDelete = findDescendants(versionToDelete.id);
+ const allToDelete = [versionToDelete, ...descendantsToDelete];
+ const idsToDelete = allToDelete.map(v => v.id);
+
+ // 2. Batch delete via API (handles storage + db)
+ await deletePictures(idsToDelete);
+
+ // 3. Update local state
+ const deletedIds = new Set(idsToDelete);
+ setVersions(prev => prev.filter(v => !deletedIds.has(v.id)));
+
+ const totalDeleted = allToDelete.length;
+ toast.success(translate(`Deleted ${totalDeleted > 1 ? `${totalDeleted} versions` : 'version'} successfully`));
+
+ } catch (error) {
+ console.error('Error deleting version:', error);
+ toast.error(translate('Failed to delete version'));
+ } finally {
+ setIsDeleting(false);
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (versions.length <= 1) {
+ return (
+
+
No other versions available for this image.
+
+ );
+ }
+
+ return (
+
+
+
+
Image Versions
+ {versions.length}
+
+
+
+ {versions.map((version) => (
+
+
+
+

+ {version.is_selected && (
+
+
+
+ )}
+
+
+
+
{version.title}
+
+ {new Date(version.created_at).toLocaleDateString()}
+
+
+ {version.parent_id === null && (
+ Original
+ )}
+ {!version.visible && (
+ Hidden
+ )}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ Delete Version
+
+ Are you sure you want to delete this version? "{versionToDelete?.title}"
+
+
+ This action cannot be undone.
+
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ confirmDelete();
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ disabled={isDeleting}
+ >
+ {isDeleting ? Deleting... : Delete}
+
+
+
+
+
+ );
+};
+
+export default VersionSelector;
diff --git a/packages/ui/src/components/logging/LogsPage.tsx b/packages/ui/src/components/logging/LogsPage.tsx
index d1c8eb14..432e6146 100644
--- a/packages/ui/src/components/logging/LogsPage.tsx
+++ b/packages/ui/src/components/logging/LogsPage.tsx
@@ -72,4 +72,4 @@ const LogsPage = () => {
);
};
-export default LogsPage;
\ No newline at end of file
+export default LogsPage;
diff --git a/packages/ui/src/components/tree/components/cursor.tsx b/packages/ui/src/components/tree/components/cursor.tsx
index 658664c1..9d7b62ec 100644
--- a/packages/ui/src/components/tree/components/cursor.tsx
+++ b/packages/ui/src/components/tree/components/cursor.tsx
@@ -1,15 +1,15 @@
-import { useDndContext, useTreeApi } from "../context";
-
-export function Cursor() {
- const tree = useTreeApi();
- const state = useDndContext();
- const cursor = state.cursor;
- if (!cursor || cursor.type !== "line") return null;
- const indent = tree.indent;
- const top =
- tree.rowHeight * cursor.index +
- (tree.props.padding ?? tree.props.paddingTop ?? 0);
- const left = indent * cursor.level;
- const Cursor = tree.renderCursor;
- return ;
-}
+import { useDndContext, useTreeApi } from "../context";
+
+export function Cursor() {
+ const tree = useTreeApi();
+ const state = useDndContext();
+ const cursor = state.cursor;
+ if (!cursor || cursor.type !== "line") return null;
+ const indent = tree.indent;
+ const top =
+ tree.rowHeight * cursor.index +
+ (tree.props.padding ?? tree.props.paddingTop ?? 0);
+ const left = indent * cursor.level;
+ const Cursor = tree.renderCursor;
+ return ;
+}
diff --git a/packages/ui/src/components/tree/components/default-container.tsx b/packages/ui/src/components/tree/components/default-container.tsx
index f92dd5cf..9960fc0a 100644
--- a/packages/ui/src/components/tree/components/default-container.tsx
+++ b/packages/ui/src/components/tree/components/default-container.tsx
@@ -1,239 +1,239 @@
-import { FixedSizeList } from "react-window";
-import { useDataUpdates, useTreeApi } from "../context";
-import { focusNextElement, focusPrevElement } from "../utils";
-import { ListOuterElement } from "./list-outer-element";
-import { ListInnerElement } from "./list-inner-element";
-import { RowContainer } from "./row-container";
-
-let focusSearchTerm = "";
-let timeoutId: any = null;
-
-/**
- * All these keyboard shortcuts seem like they should be configurable.
- * Each operation should be a given a name and separated from
- * the event handler. Future clean up welcome.
- */
-export function DefaultContainer() {
- useDataUpdates();
- const tree = useTreeApi();
- return (
- {
- if (!e.currentTarget.contains(e.relatedTarget)) {
- tree.onFocus();
- }
- }}
- onBlur={(e) => {
- if (!e.currentTarget.contains(e.relatedTarget)) {
- tree.onBlur();
- }
- }}
- onKeyDown={(e) => {
- if (tree.isEditing) {
- return;
- }
- if (e.key === "Backspace") {
- if (!tree.props.onDelete) return;
- const ids = Array.from(tree.selectedIds);
- if (ids.length > 1) {
- let nextFocus = tree.mostRecentNode;
- while (nextFocus && nextFocus.isSelected) {
- nextFocus = nextFocus.nextSibling;
- }
- if (!nextFocus) nextFocus = tree.lastNode;
- tree.focus(nextFocus, { scroll: false });
- tree.delete(Array.from(ids));
- } else {
- const node = tree.focusedNode;
- if (node) {
- const sib = node.nextSibling;
- const parent = node.parent;
- tree.focus(sib || parent, { scroll: false });
- tree.delete(node);
- }
- }
- return;
- }
- if (e.key === "Tab" && !e.shiftKey) {
- e.preventDefault();
- focusNextElement(e.currentTarget);
- return;
- }
- if (e.key === "Tab" && e.shiftKey) {
- e.preventDefault();
- focusPrevElement(e.currentTarget);
- return;
- }
- if (e.key === "ArrowDown") {
- e.preventDefault();
- const next = tree.nextNode;
- if (e.metaKey) {
- tree.select(tree.focusedNode);
- tree.activate(tree.focusedNode);
- return;
- } else if (!e.shiftKey || tree.props.disableMultiSelection) {
- tree.focus(next);
- return;
- } else {
- if (!next) return;
- const current = tree.focusedNode;
- if (!current) {
- tree.focus(tree.firstNode);
- } else if (current.isSelected) {
- tree.selectContiguous(next);
- } else {
- tree.selectMulti(next);
- }
- return;
- }
- }
- if (e.key === "ArrowUp") {
- e.preventDefault();
- const prev = tree.prevNode;
- if (!e.shiftKey || tree.props.disableMultiSelection) {
- tree.focus(prev);
- return;
- } else {
- if (!prev) return;
- const current = tree.focusedNode;
- if (!current) {
- tree.focus(tree.lastNode); // ?
- } else if (current.isSelected) {
- tree.selectContiguous(prev);
- } else {
- tree.selectMulti(prev);
- }
- return;
- }
- }
- if (e.key === "ArrowRight") {
- const node = tree.focusedNode;
- if (!node) return;
- if (node.isInternal && node.isOpen) {
- tree.focus(tree.nextNode);
- } else if (node.isInternal) tree.open(node.id);
- return;
- }
- if (e.key === "ArrowLeft") {
- const node = tree.focusedNode;
- if (!node || node.isRoot) return;
- if (node.isInternal && node.isOpen) tree.close(node.id);
- else if (!node.parent?.isRoot) {
- tree.focus(node.parent);
- }
- return;
- }
- if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
- e.preventDefault();
- tree.selectAll();
- return;
- }
- if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
- tree.createLeaf();
- return;
- }
- if (e.key === "A" && !e.metaKey) {
- if (!tree.props.onCreate) return;
- tree.createInternal();
- return;
- }
-
- if (e.key === "Home") {
- // add shift keys
- e.preventDefault();
- tree.focus(tree.firstNode);
- return;
- }
- if (e.key === "End") {
- // add shift keys
- e.preventDefault();
- tree.focus(tree.lastNode);
- return;
- }
- if (e.key === "Enter") {
- const node = tree.focusedNode;
- if (!node) return;
- if (!node.isEditable || !tree.props.onRename) return;
- setTimeout(() => {
- if (node) tree.edit(node);
- });
- return;
- }
- if (e.key === " ") {
- e.preventDefault();
- const node = tree.focusedNode;
- if (!node) return;
- if (node.isLeaf) {
- node.select();
- node.activate();
- } else {
- node.toggle();
- }
- return;
- }
- if (e.key === "*") {
- const node = tree.focusedNode;
- if (!node) return;
- tree.openSiblings(node);
- return;
- }
- if (e.key === "PageUp") {
- e.preventDefault();
- tree.pageUp();
- return;
- }
- if (e.key === "PageDown") {
- e.preventDefault();
- tree.pageDown();
- }
-
- // If they type a sequence of characters
- // collect them. Reset them after a timeout.
- // Use it to search the tree for a node, then focus it.
- // Clean this up a bit later
- clearTimeout(timeoutId);
- focusSearchTerm += e.key;
- timeoutId = setTimeout(() => {
- focusSearchTerm = "";
- }, 600);
- const node = tree.visibleNodes.find((n) => {
- // @ts-ignore
- const name = n.data.name;
- if (typeof name === "string") {
- return name.toLowerCase().startsWith(focusSearchTerm);
- } else return false;
- });
- if (node) tree.focus(node.id);
- }}
- >
- {/* @ts-ignore */}
- tree.visibleNodes[index]?.id || index}
- outerElementType={ListOuterElement}
- innerElementType={ListInnerElement}
- onScroll={tree.props.onScroll}
- onItemsRendered={tree.onItemsRendered.bind(tree)}
- ref={tree.list}
- >
- {RowContainer}
-
-
- );
-}
+import { FixedSizeList } from "react-window";
+import { useDataUpdates, useTreeApi } from "../context";
+import { focusNextElement, focusPrevElement } from "../utils";
+import { ListOuterElement } from "./list-outer-element";
+import { ListInnerElement } from "./list-inner-element";
+import { RowContainer } from "./row-container";
+
+let focusSearchTerm = "";
+let timeoutId: any = null;
+
+/**
+ * All these keyboard shortcuts seem like they should be configurable.
+ * Each operation should be a given a name and separated from
+ * the event handler. Future clean up welcome.
+ */
+export function DefaultContainer() {
+ useDataUpdates();
+ const tree = useTreeApi();
+ return (
+ {
+ if (!e.currentTarget.contains(e.relatedTarget)) {
+ tree.onFocus();
+ }
+ }}
+ onBlur={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget)) {
+ tree.onBlur();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (tree.isEditing) {
+ return;
+ }
+ if (e.key === "Backspace") {
+ if (!tree.props.onDelete) return;
+ const ids = Array.from(tree.selectedIds);
+ if (ids.length > 1) {
+ let nextFocus = tree.mostRecentNode;
+ while (nextFocus && nextFocus.isSelected) {
+ nextFocus = nextFocus.nextSibling;
+ }
+ if (!nextFocus) nextFocus = tree.lastNode;
+ tree.focus(nextFocus, { scroll: false });
+ tree.delete(Array.from(ids));
+ } else {
+ const node = tree.focusedNode;
+ if (node) {
+ const sib = node.nextSibling;
+ const parent = node.parent;
+ tree.focus(sib || parent, { scroll: false });
+ tree.delete(node);
+ }
+ }
+ return;
+ }
+ if (e.key === "Tab" && !e.shiftKey) {
+ e.preventDefault();
+ focusNextElement(e.currentTarget);
+ return;
+ }
+ if (e.key === "Tab" && e.shiftKey) {
+ e.preventDefault();
+ focusPrevElement(e.currentTarget);
+ return;
+ }
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ const next = tree.nextNode;
+ if (e.metaKey) {
+ tree.select(tree.focusedNode);
+ tree.activate(tree.focusedNode);
+ return;
+ } else if (!e.shiftKey || tree.props.disableMultiSelection) {
+ tree.focus(next);
+ return;
+ } else {
+ if (!next) return;
+ const current = tree.focusedNode;
+ if (!current) {
+ tree.focus(tree.firstNode);
+ } else if (current.isSelected) {
+ tree.selectContiguous(next);
+ } else {
+ tree.selectMulti(next);
+ }
+ return;
+ }
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ const prev = tree.prevNode;
+ if (!e.shiftKey || tree.props.disableMultiSelection) {
+ tree.focus(prev);
+ return;
+ } else {
+ if (!prev) return;
+ const current = tree.focusedNode;
+ if (!current) {
+ tree.focus(tree.lastNode); // ?
+ } else if (current.isSelected) {
+ tree.selectContiguous(prev);
+ } else {
+ tree.selectMulti(prev);
+ }
+ return;
+ }
+ }
+ if (e.key === "ArrowRight") {
+ const node = tree.focusedNode;
+ if (!node) return;
+ if (node.isInternal && node.isOpen) {
+ tree.focus(tree.nextNode);
+ } else if (node.isInternal) tree.open(node.id);
+ return;
+ }
+ if (e.key === "ArrowLeft") {
+ const node = tree.focusedNode;
+ if (!node || node.isRoot) return;
+ if (node.isInternal && node.isOpen) tree.close(node.id);
+ else if (!node.parent?.isRoot) {
+ tree.focus(node.parent);
+ }
+ return;
+ }
+ if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
+ e.preventDefault();
+ tree.selectAll();
+ return;
+ }
+ if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
+ tree.createLeaf();
+ return;
+ }
+ if (e.key === "A" && !e.metaKey) {
+ if (!tree.props.onCreate) return;
+ tree.createInternal();
+ return;
+ }
+
+ if (e.key === "Home") {
+ // add shift keys
+ e.preventDefault();
+ tree.focus(tree.firstNode);
+ return;
+ }
+ if (e.key === "End") {
+ // add shift keys
+ e.preventDefault();
+ tree.focus(tree.lastNode);
+ return;
+ }
+ if (e.key === "Enter") {
+ const node = tree.focusedNode;
+ if (!node) return;
+ if (!node.isEditable || !tree.props.onRename) return;
+ setTimeout(() => {
+ if (node) tree.edit(node);
+ });
+ return;
+ }
+ if (e.key === " ") {
+ e.preventDefault();
+ const node = tree.focusedNode;
+ if (!node) return;
+ if (node.isLeaf) {
+ node.select();
+ node.activate();
+ } else {
+ node.toggle();
+ }
+ return;
+ }
+ if (e.key === "*") {
+ const node = tree.focusedNode;
+ if (!node) return;
+ tree.openSiblings(node);
+ return;
+ }
+ if (e.key === "PageUp") {
+ e.preventDefault();
+ tree.pageUp();
+ return;
+ }
+ if (e.key === "PageDown") {
+ e.preventDefault();
+ tree.pageDown();
+ }
+
+ // If they type a sequence of characters
+ // collect them. Reset them after a timeout.
+ // Use it to search the tree for a node, then focus it.
+ // Clean this up a bit later
+ clearTimeout(timeoutId);
+ focusSearchTerm += e.key;
+ timeoutId = setTimeout(() => {
+ focusSearchTerm = "";
+ }, 600);
+ const node = tree.visibleNodes.find((n) => {
+ // @ts-ignore
+ const name = n.data.name;
+ if (typeof name === "string") {
+ return name.toLowerCase().startsWith(focusSearchTerm);
+ } else return false;
+ });
+ if (node) tree.focus(node.id);
+ }}
+ >
+ {/* @ts-ignore */}
+ tree.visibleNodes[index]?.id || index}
+ outerElementType={ListOuterElement}
+ innerElementType={ListInnerElement}
+ onScroll={tree.props.onScroll}
+ onItemsRendered={tree.onItemsRendered.bind(tree)}
+ ref={tree.list}
+ >
+ {RowContainer}
+
+
+ );
+}
diff --git a/packages/ui/src/components/tree/components/default-cursor.tsx b/packages/ui/src/components/tree/components/default-cursor.tsx
index 00565ea2..8b70ff63 100644
--- a/packages/ui/src/components/tree/components/default-cursor.tsx
+++ b/packages/ui/src/components/tree/components/default-cursor.tsx
@@ -1,42 +1,42 @@
-import React, { CSSProperties } from "react";
-import { CursorProps } from "../types/renderers";
-
-const placeholderStyle = {
- display: "flex",
- alignItems: "center",
- zIndex: 1,
-};
-
-const lineStyle = {
- flex: 1,
- height: "2px",
- background: "#4B91E2",
- borderRadius: "1px",
-};
-
-const circleStyle = {
- width: "4px",
- height: "4px",
- boxShadow: "0 0 0 3px #4B91E2",
- borderRadius: "50%",
-};
-
-export const DefaultCursor = React.memo(function DefaultCursor({
- top,
- left,
- indent,
-}: CursorProps) {
- const style: CSSProperties = {
- position: "absolute",
- pointerEvents: "none",
- top: top - 2 + "px",
- left: left + "px",
- right: indent + "px",
- };
- return (
-
- );
-});
+import React, { CSSProperties } from "react";
+import { CursorProps } from "../types/renderers";
+
+const placeholderStyle = {
+ display: "flex",
+ alignItems: "center",
+ zIndex: 1,
+};
+
+const lineStyle = {
+ flex: 1,
+ height: "2px",
+ background: "#4B91E2",
+ borderRadius: "1px",
+};
+
+const circleStyle = {
+ width: "4px",
+ height: "4px",
+ boxShadow: "0 0 0 3px #4B91E2",
+ borderRadius: "50%",
+};
+
+export const DefaultCursor = React.memo(function DefaultCursor({
+ top,
+ left,
+ indent,
+}: CursorProps) {
+ const style: CSSProperties = {
+ position: "absolute",
+ pointerEvents: "none",
+ top: top - 2 + "px",
+ left: left + "px",
+ right: indent + "px",
+ };
+ return (
+
+ );
+});
diff --git a/packages/ui/src/components/tree/components/default-drag-preview.tsx b/packages/ui/src/components/tree/components/default-drag-preview.tsx
index fb3b28a0..a90f488d 100644
--- a/packages/ui/src/components/tree/components/default-drag-preview.tsx
+++ b/packages/ui/src/components/tree/components/default-drag-preview.tsx
@@ -1,92 +1,92 @@
-import React, { CSSProperties, memo } from "react";
-import { XYCoord } from "react-dnd";
-import { useTreeApi } from "../context";
-import { DragPreviewProps } from "../types/renderers";
-import { IdObj } from "../types/utils";
-
-const layerStyles: CSSProperties = {
- position: "fixed",
- pointerEvents: "none",
- zIndex: 100,
- left: 0,
- top: 0,
- width: "100%",
- height: "100%",
-};
-
-const getStyle = (offset: XYCoord | null) => {
- if (!offset) return { display: "none" };
- const { x, y } = offset;
- return { transform: `translate(${x}px, ${y}px)` };
-};
-
-const getCountStyle = (offset: XYCoord | null) => {
- if (!offset) return { display: "none" };
- const { x, y } = offset;
- return { transform: `translate(${x + 10}px, ${y + 10}px)` };
-};
-
-export function DefaultDragPreview({
- offset,
- mouse,
- id,
- dragIds,
- isDragging,
-}: DragPreviewProps) {
- return (
-
-
-
-
-
-
- );
-}
-
-const Overlay = memo(function Overlay(props: {
- children: JSX.Element[];
- isDragging: boolean;
-}) {
- if (!props.isDragging) return null;
- return {props.children}
;
-});
-
-function Position(props: { children: JSX.Element; offset: XYCoord | null }) {
- return (
-
- {props.children}
-
- );
-}
-
-function Count(props: { count: number; mouse: XYCoord | null }) {
- const { count, mouse } = props;
- if (count > 1)
- return (
-
- {count}
-
- );
- else return null;
-}
-
-const PreviewNode = memo(function PreviewNode(props: {
- id: string | null;
- dragIds: string[];
-}) {
- const tree = useTreeApi();
- const node = tree.get(props.id);
- if (!node) return null;
- return (
-
- );
-});
+import React, { CSSProperties, memo } from "react";
+import { XYCoord } from "react-dnd";
+import { useTreeApi } from "../context";
+import { DragPreviewProps } from "../types/renderers";
+import { IdObj } from "../types/utils";
+
+const layerStyles: CSSProperties = {
+ position: "fixed",
+ pointerEvents: "none",
+ zIndex: 100,
+ left: 0,
+ top: 0,
+ width: "100%",
+ height: "100%",
+};
+
+const getStyle = (offset: XYCoord | null) => {
+ if (!offset) return { display: "none" };
+ const { x, y } = offset;
+ return { transform: `translate(${x}px, ${y}px)` };
+};
+
+const getCountStyle = (offset: XYCoord | null) => {
+ if (!offset) return { display: "none" };
+ const { x, y } = offset;
+ return { transform: `translate(${x + 10}px, ${y + 10}px)` };
+};
+
+export function DefaultDragPreview({
+ offset,
+ mouse,
+ id,
+ dragIds,
+ isDragging,
+}: DragPreviewProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+const Overlay = memo(function Overlay(props: {
+ children: JSX.Element[];
+ isDragging: boolean;
+}) {
+ if (!props.isDragging) return null;
+ return {props.children}
;
+});
+
+function Position(props: { children: JSX.Element; offset: XYCoord | null }) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function Count(props: { count: number; mouse: XYCoord | null }) {
+ const { count, mouse } = props;
+ if (count > 1)
+ return (
+
+ {count}
+
+ );
+ else return null;
+}
+
+const PreviewNode = memo(function PreviewNode(props: {
+ id: string | null;
+ dragIds: string[];
+}) {
+ const tree = useTreeApi();
+ const node = tree.get(props.id);
+ if (!node) return null;
+ return (
+
+ );
+});
diff --git a/packages/ui/src/components/tree/components/default-node.tsx b/packages/ui/src/components/tree/components/default-node.tsx
index 48a40b8c..f14d3ded 100644
--- a/packages/ui/src/components/tree/components/default-node.tsx
+++ b/packages/ui/src/components/tree/components/default-node.tsx
@@ -1,50 +1,50 @@
-import React, { useEffect, useRef } from "react";
-import { NodeRendererProps } from "../types/renderers";
-import { IdObj } from "../types/utils";
-
-export function DefaultNode(props: NodeRendererProps) {
- return (
-
- {
- e.stopPropagation();
- props.node.toggle();
- }}
- >
- {props.node.isLeaf ? "🌳" : props.node.isOpen ? "🗁" : "🗀"}
- {" "}
- {props.node.isEditing ? : }
-
- );
-}
-
-function Show(props: NodeRendererProps) {
- return (
- <>
- {/* @ts-ignore */}
- {props.node.data.name}
- >
- );
-}
-
-function Edit({ node }: NodeRendererProps) {
- const input = useRef();
-
- useEffect(() => {
- input.current?.focus();
- input.current?.select();
- }, []);
-
- return (
- node.reset()}
- onKeyDown={(e) => {
- if (e.key === "Escape") node.reset();
- if (e.key === "Enter") node.submit(input.current?.value || "");
- }}
- >
- );
-}
+import React, { useEffect, useRef } from "react";
+import { NodeRendererProps } from "../types/renderers";
+import { IdObj } from "../types/utils";
+
+export function DefaultNode(props: NodeRendererProps) {
+ return (
+
+ {
+ e.stopPropagation();
+ props.node.toggle();
+ }}
+ >
+ {props.node.isLeaf ? "🌳" : props.node.isOpen ? "🗁" : "🗀"}
+ {" "}
+ {props.node.isEditing ? : }
+
+ );
+}
+
+function Show(props: NodeRendererProps) {
+ return (
+ <>
+ {/* @ts-ignore */}
+ {props.node.data.name}
+ >
+ );
+}
+
+function Edit({ node }: NodeRendererProps) {
+ const input = useRef();
+
+ useEffect(() => {
+ input.current?.focus();
+ input.current?.select();
+ }, []);
+
+ return (
+ node.reset()}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") node.reset();
+ if (e.key === "Enter") node.submit(input.current?.value || "");
+ }}
+ >
+ );
+}
diff --git a/packages/ui/src/components/tree/components/default-row.tsx b/packages/ui/src/components/tree/components/default-row.tsx
index 9935bf63..4a28dfd7 100644
--- a/packages/ui/src/components/tree/components/default-row.tsx
+++ b/packages/ui/src/components/tree/components/default-row.tsx
@@ -1,21 +1,21 @@
-import React from "react";
-import { RowRendererProps } from "../types/renderers";
-import { IdObj } from "../types/utils";
-
-export function DefaultRow({
- node,
- attrs,
- innerRef,
- children,
-}: RowRendererProps) {
- return (
- e.stopPropagation()}
- onClick={node.handleClick}
- >
- {children}
-
- );
-}
+import React from "react";
+import { RowRendererProps } from "../types/renderers";
+import { IdObj } from "../types/utils";
+
+export function DefaultRow({
+ node,
+ attrs,
+ innerRef,
+ children,
+}: RowRendererProps) {
+ return (
+ e.stopPropagation()}
+ onClick={node.handleClick}
+ >
+ {children}
+
+ );
+}
diff --git a/packages/ui/src/components/tree/components/drag-preview-container.tsx b/packages/ui/src/components/tree/components/drag-preview-container.tsx
index 1c174fed..a652c728 100644
--- a/packages/ui/src/components/tree/components/drag-preview-container.tsx
+++ b/packages/ui/src/components/tree/components/drag-preview-container.tsx
@@ -1,26 +1,26 @@
-import { useDragLayer } from "react-dnd";
-import { useDndContext, useTreeApi } from "../context";
-import { DefaultDragPreview } from "./default-drag-preview";
-
-export function DragPreviewContainer() {
- const tree = useTreeApi();
- const { offset, mouse, item, isDragging } = useDragLayer((m) => {
- return {
- offset: m.getSourceClientOffset(),
- mouse: m.getClientOffset(),
- item: m.getItem(),
- isDragging: m.isDragging(),
- };
- });
-
- const DragPreview = tree.props.renderDragPreview || DefaultDragPreview;
- return (
-
- );
-}
+import { useDragLayer } from "react-dnd";
+import { useDndContext, useTreeApi } from "../context";
+import { DefaultDragPreview } from "./default-drag-preview";
+
+export function DragPreviewContainer() {
+ const tree = useTreeApi();
+ const { offset, mouse, item, isDragging } = useDragLayer((m) => {
+ return {
+ offset: m.getSourceClientOffset(),
+ mouse: m.getClientOffset(),
+ item: m.getItem(),
+ isDragging: m.isDragging(),
+ };
+ });
+
+ const DragPreview = tree.props.renderDragPreview || DefaultDragPreview;
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/tree/components/list-inner-element.tsx b/packages/ui/src/components/tree/components/list-inner-element.tsx
index e4ba3b9a..d9cca8d9 100644
--- a/packages/ui/src/components/tree/components/list-inner-element.tsx
+++ b/packages/ui/src/components/tree/components/list-inner-element.tsx
@@ -1,22 +1,22 @@
-import React from "react";
-import { forwardRef } from "react";
-import { useTreeApi } from "../context";
-
-export const ListInnerElement = forwardRef(function InnerElement(
- { style, ...rest },
- ref
-) {
- const tree = useTreeApi();
- const paddingTop = tree.props.padding ?? tree.props.paddingTop ?? 0;
- const paddingBottom = tree.props.padding ?? tree.props.paddingBottom ?? 0;
- return (
-
- );
-});
+import React from "react";
+import { forwardRef } from "react";
+import { useTreeApi } from "../context";
+
+export const ListInnerElement = forwardRef(function InnerElement(
+ { style, ...rest },
+ ref
+) {
+ const tree = useTreeApi();
+ const paddingTop = tree.props.padding ?? tree.props.paddingTop ?? 0;
+ const paddingBottom = tree.props.padding ?? tree.props.paddingBottom ?? 0;
+ return (
+
+ );
+});
diff --git a/packages/ui/src/components/tree/components/list-outer-element.tsx b/packages/ui/src/components/tree/components/list-outer-element.tsx
index 672419e4..729e2fd7 100644
--- a/packages/ui/src/components/tree/components/list-outer-element.tsx
+++ b/packages/ui/src/components/tree/components/list-outer-element.tsx
@@ -1,42 +1,42 @@
-import { forwardRef } from "react";
-import { useTreeApi } from "../context";
-import { treeBlur } from "../state/focus-slice";
-import { Cursor } from "./cursor";
-
-export const ListOuterElement = forwardRef(function Outer(
- props: React.HTMLProps,
- ref
-) {
- const { children, ...rest } = props;
- const tree = useTreeApi();
- return (
- {
- if (e.currentTarget === e.target) tree.deselectAll();
- }}
- >
-
- {children}
-
- );
-});
-
-const DropContainer = () => {
- const tree = useTreeApi();
- return (
-
-
-
- );
-};
+import { forwardRef } from "react";
+import { useTreeApi } from "../context";
+import { treeBlur } from "../state/focus-slice";
+import { Cursor } from "./cursor";
+
+export const ListOuterElement = forwardRef(function Outer(
+ props: React.HTMLProps,
+ ref
+) {
+ const { children, ...rest } = props;
+ const tree = useTreeApi();
+ return (
+ {
+ if (e.currentTarget === e.target) tree.deselectAll();
+ }}
+ >
+
+ {children}
+
+ );
+});
+
+const DropContainer = () => {
+ const tree = useTreeApi();
+ return (
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/tree/components/outer-drop.ts b/packages/ui/src/components/tree/components/outer-drop.ts
index 944d08ca..a35fbd92 100644
--- a/packages/ui/src/components/tree/components/outer-drop.ts
+++ b/packages/ui/src/components/tree/components/outer-drop.ts
@@ -1,7 +1,7 @@
-import { ReactElement } from "react";
-import { useOuterDrop } from "../dnd/outer-drop-hook";
-
-export function OuterDrop(props: { children: ReactElement }) {
- useOuterDrop();
- return props.children;
-}
+import { ReactElement } from "react";
+import { useOuterDrop } from "../dnd/outer-drop-hook";
+
+export function OuterDrop(props: { children: ReactElement }) {
+ useOuterDrop();
+ return props.children;
+}
diff --git a/packages/ui/src/components/tree/components/provider.tsx b/packages/ui/src/components/tree/components/provider.tsx
index 0326c82a..6a98ca25 100644
--- a/packages/ui/src/components/tree/components/provider.tsx
+++ b/packages/ui/src/components/tree/components/provider.tsx
@@ -1,98 +1,98 @@
-import {
- ReactNode,
- useEffect,
- useImperativeHandle,
- useMemo,
- useRef,
-} from "react";
-import { useSyncExternalStore } from "use-sync-external-store/shim";
-import { FixedSizeList } from "react-window";
-import {
- DataUpdatesContext,
- DndContext,
- NodesContext,
- TreeApiContext,
-} from "../context";
-import { TreeApi } from "../interfaces/tree-api";
-import { initialState } from "../state/initial";
-import { Actions, rootReducer, RootState } from "../state/root-reducer";
-import { HTML5Backend } from "react-dnd-html5-backend";
-import { DndProvider } from "react-dnd";
-import { TreeProps } from "../types/tree-props";
-import { createStore, Store } from "redux";
-import { actions as visibility } from "../state/open-slice";
-
-type Props = {
- treeProps: TreeProps;
- imperativeHandle: React.Ref | undefined>;
- children: ReactNode;
-};
-
-const SERVER_STATE = initialState();
-
-export function TreeProvider({
- treeProps,
- imperativeHandle,
- children,
-}: Props) {
- const list = useRef(null);
- const listEl = useRef(null);
- const store = useRef>(
- // @ts-ignore
- createStore(rootReducer, initialState(treeProps))
- );
- const state = useSyncExternalStore(
- store.current.subscribe,
- store.current.getState,
- () => SERVER_STATE
- );
-
- /* The tree api object is stable. */
- const api = useMemo(() => {
- return new TreeApi(store.current, treeProps, list, listEl);
- }, []);
-
- /* Make sure the tree instance stays in sync */
- const updateCount = useRef(0);
- useMemo(() => {
- updateCount.current += 1;
- api.update(treeProps);
- }, [...Object.values(treeProps), state.nodes.open]);
-
- /* Expose the tree api */
- useImperativeHandle(imperativeHandle, () => api);
-
- /* Change selection based on props */
- useEffect(() => {
- if (api.props.selection) {
- api.select(api.props.selection, { focus: false });
- } else {
- api.deselectAll();
- }
- }, [api.props.selection]);
-
- /* Clear visability for filtered nodes */
- useEffect(() => {
- if (!api.props.searchTerm) {
- store.current.dispatch(visibility.clear(true));
- }
- }, [api.props.searchTerm]);
-
- return (
-
-
-
-
-
- {children}
-
-
-
-
-
- );
-}
+import {
+ ReactNode,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+} from "react";
+import { useSyncExternalStore } from "use-sync-external-store/shim";
+import { FixedSizeList } from "react-window";
+import {
+ DataUpdatesContext,
+ DndContext,
+ NodesContext,
+ TreeApiContext,
+} from "../context";
+import { TreeApi } from "../interfaces/tree-api";
+import { initialState } from "../state/initial";
+import { Actions, rootReducer, RootState } from "../state/root-reducer";
+import { HTML5Backend } from "react-dnd-html5-backend";
+import { DndProvider } from "react-dnd";
+import { TreeProps } from "../types/tree-props";
+import { createStore, Store } from "redux";
+import { actions as visibility } from "../state/open-slice";
+
+type Props = {
+ treeProps: TreeProps;
+ imperativeHandle: React.Ref | undefined>;
+ children: ReactNode;
+};
+
+const SERVER_STATE = initialState();
+
+export function TreeProvider({
+ treeProps,
+ imperativeHandle,
+ children,
+}: Props) {
+ const list = useRef(null);
+ const listEl = useRef(null);
+ const store = useRef>(
+ // @ts-ignore
+ createStore(rootReducer, initialState(treeProps))
+ );
+ const state = useSyncExternalStore(
+ store.current.subscribe,
+ store.current.getState,
+ () => SERVER_STATE
+ );
+
+ /* The tree api object is stable. */
+ const api = useMemo(() => {
+ return new TreeApi(store.current, treeProps, list, listEl);
+ }, []);
+
+ /* Make sure the tree instance stays in sync */
+ const updateCount = useRef(0);
+ useMemo(() => {
+ updateCount.current += 1;
+ api.update(treeProps);
+ }, [...Object.values(treeProps), state.nodes.open]);
+
+ /* Expose the tree api */
+ useImperativeHandle(imperativeHandle, () => api);
+
+ /* Change selection based on props */
+ useEffect(() => {
+ if (api.props.selection) {
+ api.select(api.props.selection, { focus: false });
+ } else {
+ api.deselectAll();
+ }
+ }, [api.props.selection]);
+
+ /* Clear visability for filtered nodes */
+ useEffect(() => {
+ if (!api.props.searchTerm) {
+ store.current.dispatch(visibility.clear(true));
+ }
+ }, [api.props.searchTerm]);
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/tree/components/row-container.tsx b/packages/ui/src/components/tree/components/row-container.tsx
index 1018d409..7b124b04 100644
--- a/packages/ui/src/components/tree/components/row-container.tsx
+++ b/packages/ui/src/components/tree/components/row-container.tsx
@@ -1,83 +1,83 @@
-import React, { useCallback, useEffect, useMemo, useRef } from "react";
-import { useDataUpdates, useNodesContext, useTreeApi } from "../context";
-import { useDragHook } from "../dnd/drag-hook";
-import { useDropHook } from "../dnd/drop-hook";
-import { useFreshNode } from "../hooks/use-fresh-node";
-
-type Props = {
- style: React.CSSProperties;
- index: number;
-};
-
-export const RowContainer = React.memo(function RowContainer({
- index,
- style,
-}: Props) {
- /* When will the will re-render.
- *
- * The row component is memo'd so it will only render
- * when a new instance of the NodeApi class is passed
- * to it.
- *
- * The TreeApi instance is stable. It does not
- * change when the internal state changes.
- *
- * The TreeApi has all the references to the nodes.
- * We need to clone the nodes when their state
- * changes. The node class contains no state itself,
- * It always checks the tree for state. The tree's
- * state will always be up to date.
- */
-
- useDataUpdates(); // Re-render when tree props or visability changes
- const _ = useNodesContext(); // So that we re-render appropriately
- const tree = useTreeApi(); // Tree already has the fresh state
- const node = useFreshNode(index);
-
- const el = useRef(null);
- const dragRef = useDragHook(node);
- const dropRef = useDropHook(el, node);
- const innerRef = useCallback(
- (n: any) => {
- el.current = n;
- dropRef(n);
- },
- [dropRef]
- );
-
- const indent = tree.indent * node.level;
- const nodeStyle = useMemo(() => ({ paddingLeft: indent }), [indent]);
- const rowStyle = useMemo(
- () => ({
- ...style,
- top:
- parseFloat(style.top as string) +
- (tree.props.padding ?? tree.props.paddingTop ?? 0),
- }),
- [style, tree.props.padding, tree.props.paddingTop]
- );
- const rowAttrs: React.HTMLAttributes = {
- role: "treeitem",
- "aria-level": node.level + 1,
- "aria-selected": node.isSelected,
- "aria-expanded": node.isOpen,
- style: rowStyle,
- tabIndex: -1,
- className: tree.props.rowClassName,
- };
-
- useEffect(() => {
- if (!node.isEditing && node.isFocused) {
- el.current?.focus({ preventScroll: true });
- }
- }, [node.isEditing, node.isFocused, el.current]);
-
- const Node = tree.renderNode;
- const Row = tree.renderRow;
-
- return (
-
-
-
- );
-});
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useDataUpdates, useNodesContext, useTreeApi } from "../context";
+import { useDragHook } from "../dnd/drag-hook";
+import { useDropHook } from "../dnd/drop-hook";
+import { useFreshNode } from "../hooks/use-fresh-node";
+
+type Props = {
+ style: React.CSSProperties;
+ index: number;
+};
+
+export const RowContainer = React.memo(function RowContainer({
+ index,
+ style,
+}: Props) {
+ /* When will the will re-render.
+ *
+ * The row component is memo'd so it will only render
+ * when a new instance of the NodeApi class is passed
+ * to it.
+ *
+ * The TreeApi instance is stable. It does not
+ * change when the internal state changes.
+ *
+ * The TreeApi has all the references to the nodes.
+ * We need to clone the nodes when their state
+ * changes. The node class contains no state itself,
+ * It always checks the tree for state. The tree's
+ * state will always be up to date.
+ */
+
+ useDataUpdates(); // Re-render when tree props or visability changes
+ const _ = useNodesContext(); // So that we re-render appropriately
+ const tree = useTreeApi