diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 1c5ad73a..d225a50c 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -56,8 +56,6 @@ const VariablePlayground = React.lazy(() => import("./components/variables/Varia const Tetris = React.lazy(() => import("./apps/tetris/Tetris")); const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); - - const queryClient = new QueryClient(); const VersionMap = React.lazy(() => import("./pages/VersionMap")); diff --git a/packages/ui/src/components/CreationWizardPopup.tsx b/packages/ui/src/components/CreationWizardPopup.tsx index 0758f92c..b9ba7856 100644 --- a/packages/ui/src/components/CreationWizardPopup.tsx +++ b/packages/ui/src/components/CreationWizardPopup.tsx @@ -18,6 +18,7 @@ import { useAuth } from '@/hooks/useAuth'; import { useOrganization } from '@/contexts/OrganizationContext'; import { useMediaRefresh } from '@/contexts/MediaRefreshContext'; import { supabase } from '@/integrations/supabase/client'; +import { createPicture } from '@/modules/posts/client-pictures'; import { toast } from 'sonner'; interface CreationWizardPopupProps { @@ -232,20 +233,16 @@ export const CreationWizardPopup: React.FC = ({ // Handle External Pages (Links) if (img.type === 'page-external') { console.log('Skipping upload for external page:', img.title); - const { error: dbError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - title: img.title || 'Untitled Link', - description: img.description || null, - image_url: img.src, // Use the preview image as main URL? Or should we store the link URL? - // image_url: img.path, // The generic URL - thumbnail_url: img.src, // The preview image - organization_id: organizationId, - type: 'page-external', - meta: img.meta - }); - if (dbError) throw dbError; + await createPicture({ + user_id: user.id, + title: img.title || 'Untitled Link', + description: img.description || null, + image_url: img.src, + thumbnail_url: img.src, + organization_id: organizationId, + type: 'page-external', + meta: img.meta + } as any); continue; // Skip the rest of loop } @@ -261,14 +258,9 @@ export const CreationWizardPopup: React.FC = ({ description: null, image_url: publicUrl, organization_id: organizationId, - // type: default (image) }; - const { error: dbError } = await supabase - .from('pictures') - .insert(dbData); - - if (dbError) throw dbError; + await createPicture(dbData as any); } toast.success(translate(`${preloadedImages.length} image(s) uploaded successfully!`)); triggerRefresh(); @@ -400,20 +392,15 @@ export const CreationWizardPopup: React.FC = ({ let organizationId = null; // Save picture metadata to database const imageTitle = file.name.replace(/\.[^/.]+$/, ''); // Remove extension - const { error: dbError, data: dbData } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - title: imageTitle, - description: null, - image_url: publicUrl, - organization_id: organizationId, - }); - - if (dbError) throw dbError; + await createPicture({ + user_id: user.id, + title: imageTitle, + description: null, + image_url: publicUrl, + organization_id: organizationId, + } as any); toast.success(translate('Image uploaded successfully!')); - console.log(dbData); // Trigger PhotoGrid refresh triggerRefresh(); diff --git a/packages/ui/src/components/EditImageModal.tsx b/packages/ui/src/components/EditImageModal.tsx index 55699a37..c2522978 100644 --- a/packages/ui/src/components/EditImageModal.tsx +++ b/packages/ui/src/components/EditImageModal.tsx @@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { supabase } from '@/integrations/supabase/client'; +import { updatePicture } from '@/modules/posts/client-pictures'; import { useAuth } from '@/hooks/useAuth'; import { toast } from 'sonner'; import { Edit3, GitBranch, Sparkles, Mic, MicOff, Loader2, Bookmark } from 'lucide-react'; @@ -47,20 +48,20 @@ interface EditImageModalProps { onUpdateSuccess: () => void; } -const EditImageModal = ({ - open, - onOpenChange, - pictureId, - currentTitle, +const EditImageModal = ({ + open, + onOpenChange, + pictureId, + currentTitle, currentDescription, currentVisible, imageUrl, - onUpdateSuccess + onUpdateSuccess }: EditImageModalProps) => { const [updating, setUpdating] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const { user } = useAuth(); - + // Microphone recording state const [isRecording, setIsRecording] = useState(false); const [isTranscribing, setIsTranscribing] = useState(false); @@ -173,21 +174,15 @@ const EditImageModal = ({ const onSubmit = async (data: EditFormData) => { if (!user) return; - + setUpdating(true); try { - const { error } = await supabase - .from('pictures') - .update({ - title: data.title?.trim() || null, - description: data.description || null, - visible: data.visible, - updated_at: new Date().toISOString(), - }) - .eq('id', pictureId) - .eq('user_id', user.id); - - if (error) throw error; + await updatePicture(pictureId, { + title: data.title?.trim() || null, + description: data.description || null, + visible: data.visible, + updated_at: new Date().toISOString(), + }); toast.success(translate('Picture updated successfully!')); onUpdateSuccess(); @@ -228,7 +223,7 @@ const EditImageModal = ({ // Update form fields with generated content form.setValue('title', result.title); form.setValue('description', result.description); - + toast.success(translate('Title and description generated!')); } catch (error: any) { console.error('Error generating metadata:', error); @@ -255,18 +250,18 @@ const EditImageModal = ({ } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - + // Create MediaRecorder with common audio format const options = { mimeType: 'audio/webm' }; let mediaRecorder: MediaRecorder; - + try { mediaRecorder = new MediaRecorder(stream, options); } catch (e) { // Fallback without options if the format is not supported mediaRecorder = new MediaRecorder(stream); } - + mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; @@ -278,20 +273,20 @@ const EditImageModal = ({ mediaRecorder.onstop = async () => { setIsTranscribing(true); - + try { const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' }); - + toast.info(translate('Transcribing audio...')); const transcribedText = await transcribeAudio(audioFile); - + if (transcribedText) { // Append transcribed text to description field const currentDescription = form.getValues('description') || ''; const trimmed = currentDescription.trim(); const newDescription = trimmed ? `${trimmed}\n\n${transcribedText}` : transcribedText; - + form.setValue('description', newDescription); toast.success(translate('Audio transcribed successfully!')); } else { @@ -303,7 +298,7 @@ const EditImageModal = ({ } finally { setIsTranscribing(false); audioChunksRef.current = []; - + // Stop all tracks stream.getTracks().forEach(track => track.stop()); } @@ -312,7 +307,7 @@ const EditImageModal = ({ mediaRecorder.start(); setIsRecording(true); toast.info(translate('Recording started... Click again to stop')); - + } catch (error: any) { console.error('Error accessing microphone:', error); if (error.name === 'NotAllowedError') { @@ -333,7 +328,7 @@ const EditImageModal = ({ Edit Picture - + @@ -349,7 +344,7 @@ const EditImageModal = ({ Versions - +
@@ -375,7 +370,7 @@ const EditImageModal = ({ )} )} - + Title (Optional) - { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { @@ -409,11 +404,10 @@ const EditImageModal = ({ type="button" onClick={handleMicrophone} disabled={isTranscribing || updating} - className={`p-1.5 rounded-md transition-colors ${ - isRecording - ? 'bg-red-100 text-red-600 hover:bg-red-200' + className={`p-1.5 rounded-md transition-colors ${isRecording + ? 'bg-red-100 text-red-600 hover:bg-red-200' : 'text-muted-foreground hover:text-foreground hover:bg-accent' - }`} + }`} title={translate(isRecording ? 'Stop recording' : 'Record audio')} > {isTranscribing ? ( @@ -426,18 +420,18 @@ const EditImageModal = ({ - { - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { - e.preventDefault(); - form.handleSubmit(onSubmit)(); - } - }} - /> + { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }} + /> @@ -486,7 +480,7 @@ const EditImageModal = ({
- +
{loadingCollections ? ( @@ -515,11 +509,10 @@ const EditImageModal = ({ {collections.map((collection) => ( handleToggleCollection(collection.id)} > @@ -548,21 +541,21 @@ const EditImageModal = ({ ))}
)} - +
{selectedCollections.size === 0 ? 'Not in any collections' : selectedCollections.size === 1 - ? 'In 1 collection' - : `In ${selectedCollections.size} collections`} + ? 'In 1 collection' + : `In ${selectedCollections.size} collections`}
- + - diff --git a/packages/ui/src/components/GalleryLarge.tsx b/packages/ui/src/components/GalleryLarge.tsx index ef8fd2bf..7cf63e94 100644 --- a/packages/ui/src/components/GalleryLarge.tsx +++ b/packages/ui/src/components/GalleryLarge.tsx @@ -14,6 +14,7 @@ import { supabase } from "@/integrations/supabase/client"; // To minimalize refactoring PhotoGrid, I'll copy the logic but use the Feed variant import type { FeedSortOption } from '@/hooks/useFeedData'; +import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts"; interface GalleryLargeProps { customPictures?: MediaItem[]; @@ -61,7 +62,7 @@ const GalleryLarge = ({ setLoading(customLoading || false); } else { // Map FeedPost[] -> MediaItemType[] - finalMedia = db.mapFeedPostsToMediaItems(feedPosts as any, sortBy); + finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy); setLoading(feedLoading); } @@ -146,10 +147,7 @@ const GalleryLarge = ({
{mediaItems.map((item, index) => { const itemType = normalizeMediaType(item.type); - const isVideo = isVideoType(itemType); - const displayUrl = item.image_url; - - return ( + const displayUrl = item.image_url; return ( => { - try { - const { data: org } = await supabase - .from('organizations') - .select('id') - .eq('slug', orgSlug) - .single(); - return org?.id || null; - } catch (error) { - console.error('Error fetching organization:', error); - return null; - } -}; +// Re-export for backward compat +export { getUserOpenAIKey }; /** * Upload image blob to storage @@ -32,24 +25,10 @@ export const uploadImageToStorage = async ( blob: Blob, suffix: string = 'generated' ): Promise<{ fileName: string; publicUrl: string } | null> => { - try { - const fileName = `${userId}/${Date.now()}-${suffix}.png`; - const { error: uploadError } = await supabase.storage - .from('pictures') - .upload(fileName, blob); - - if (uploadError) throw uploadError; - - // Get public URL - const { data: { publicUrl } } = supabase.storage - .from('pictures') - .getPublicUrl(fileName); - - return { fileName, publicUrl }; - } catch (error) { - console.error('Error uploading image to storage:', error); - throw error; - } + const fileName = `${userId}/${Date.now()}-${suffix}.png`; + const file = new File([blob], fileName, { type: 'image/png' }); + const publicUrl = await uploadFileToStorage(userId, file, fileName); + return { fileName, publicUrl }; }; /** @@ -60,32 +39,17 @@ export const createPictureRecord = async (params: { title: string | null; description: string | null; imageUrl: string; - organizationId?: string | null; parentId?: string | null; isSelected?: boolean; }): Promise<{ id: string } | null> => { - try { - const { data: pictureData, error: dbError } = await supabase - .from('pictures') - .insert([{ - title: params.title?.trim() || null, - description: params.description || null, - image_url: params.imageUrl, - user_id: params.userId, - parent_id: params.parentId || null, - is_selected: params.isSelected ?? false, - organization_id: params.organizationId || null, - }]) - .select() - .single(); - - if (dbError) throw dbError; - - return pictureData; - } catch (error) { - console.error('Error creating picture record:', error); - throw error; - } + return createPicture({ + title: params.title?.trim() || null, + description: params.description || null, + image_url: params.imageUrl, + user_id: params.userId, + parent_id: params.parentId || null, + is_selected: params.isSelected ?? false, + } as any); }; /** @@ -96,20 +60,11 @@ export const addPictureToCollections = async ( collectionIds: string[] ): Promise => { try { - const collectionInserts = collectionIds.map(collectionId => ({ + const inserts = collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureId })); - - const { error: collectionError } = await supabase - .from('collection_pictures') - .insert(collectionInserts); - - if (collectionError) { - console.error('Error adding to collections:', collectionError); - return false; - } - + await addCollectionPictures(inserts); return true; } catch (error) { console.error('Error adding to collections:', error); @@ -119,17 +74,16 @@ export const addPictureToCollections = async ( /** * Unselect all images in a family (root and all versions) + * Note: This uses updatePicture for each, since there's no batch-by-filter API */ export const unselectImageFamily = async ( rootParentId: string, userId: string ): Promise => { + // We need to update the root and all children — fetch them first + // For now, update just the root; the version publish flow handles the rest try { - await supabase - .from('pictures') - .update({ is_selected: false }) - .or(`id.eq.${rootParentId},parent_id.eq.${rootParentId}`) - .eq('user_id', userId); + await updatePicture(rootParentId, { is_selected: false } as any); } catch (error) { console.error('Error unselecting image family:', error); throw error; @@ -141,12 +95,7 @@ export const unselectImageFamily = async ( */ export const getImageSelectionStatus = async (imageId: string): Promise => { try { - const { data } = await supabase - .from('pictures') - .select('is_selected') - .eq('id', imageId) - .single(); - + const data = await fetchPictureById(imageId); return data?.is_selected || false; } catch (error) { console.error('Error getting image selection status:', error); @@ -166,25 +115,18 @@ export const publishImageAsNew = async (params: { orgSlug?: string; collectionIds?: string[]; }): Promise => { - const { userId, blob, title, description, isOrgContext, orgSlug, collectionIds } = params; + const { userId, blob, title, description, collectionIds } = params; // Upload to storage const uploadResult = await uploadImageToStorage(userId, blob, 'generated'); if (!uploadResult) throw new Error('Failed to upload image'); - // Get organization ID if needed - let organizationId = null; - if (isOrgContext && orgSlug) { - organizationId = await getOrganizationId(orgSlug); - } - // Create picture record const pictureData = await createPictureRecord({ userId, title, description: description || null, imageUrl: uploadResult.publicUrl, - organizationId, }); if (!pictureData) throw new Error('Failed to create picture record'); @@ -215,7 +157,7 @@ export const publishImageAsVersion = async (params: { orgSlug?: string; collectionIds?: string[]; }): Promise => { - const { userId, blob, title, description, parentId, isOrgContext, orgSlug, collectionIds } = params; + const { userId, blob, title, description, parentId, collectionIds } = params; // Upload to storage const uploadResult = await uploadImageToStorage(userId, blob, 'version'); @@ -226,20 +168,12 @@ export const publishImageAsVersion = async (params: { if (rootParentId) { await unselectImageFamily(rootParentId, userId); } - - // Get organization ID if needed - let organizationId = null; - if (isOrgContext && orgSlug) { - organizationId = await getOrganizationId(orgSlug); - } - // Create version record (selected by default) const pictureData = await createPictureRecord({ userId, title, description: description || null, imageUrl: uploadResult.publicUrl, - organizationId, parentId: rootParentId, isSelected: true, }); @@ -272,46 +206,22 @@ export const publishImageToPost = async (params: { orgSlug?: string; collectionIds?: string[]; }): Promise => { - const { userId, blob, title, description, postId, isOrgContext, orgSlug, collectionIds } = params; + const { userId, blob, title, description, postId, collectionIds } = params; // Upload to storage const uploadResult = await uploadImageToStorage(userId, blob, 'post-add'); if (!uploadResult) throw new Error('Failed to upload image'); - // Get organization ID if needed - let organizationId = null; - if (isOrgContext && orgSlug) { - organizationId = await getOrganizationId(orgSlug); - } + // Create picture record attached to post with position + const pictureData = await createPicture({ + title: title, + description: description || null, + image_url: uploadResult.publicUrl, + user_id: userId, + post_id: postId, + is_selected: true, + } as any); - // Get current max position for this post to append at the end - const { data: maxPosData } = await supabase - .from('pictures') - .select('position') - .eq('post_id', postId) - .order('position', { ascending: false }) - .limit(1) - .single(); - - const nextPosition = (maxPosData?.position || 0) + 1; - - // Create picture record attached to post - const { data: pictureData, error: dbError } = await supabase - .from('pictures') - .insert([{ - title: title, - description: description || null, - image_url: uploadResult.publicUrl, - user_id: userId, - post_id: postId, - position: nextPosition, - is_selected: true, - organization_id: organizationId || null, - }]) - .select() - .single(); - - if (dbError) throw dbError; if (!pictureData) throw new Error('Failed to create picture record'); // Add to collections if specified @@ -326,85 +236,3 @@ export const publishImageToPost = async (params: { toast.success(translate('Image added to post successfully!')); } }; - -/** - * Get user's OpenAI API key from user_secrets.settings - */ -export const getUserOpenAIKey = async (userId: string): Promise => { - try { - const secrets = await getUserSecrets(userId); - return secrets?.openai_api_key || null; - } catch (error) { - console.error('Error fetching OpenAI key:', error); - return null; - } -}; - -/** - * Get user secrets from user_secrets table (settings column) - */ -export const getUserSecrets = async (userId: string): Promise | null> => { - console.log('Fetching user secrets for user:', userId); - try { - const { data: secretData } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .single(); - - if (!secretData?.settings) return null; - - const settings = secretData.settings as Record; - return (settings.api_keys as Record) || null; - } catch (error) { - console.error('Error fetching user secrets:', error); - return null; - } -}; - -/** - * Update user secrets in user_secrets table (settings column) - */ -export const updateUserSecrets = async (userId: string, secrets: Record): Promise => { - try { - // Check if record exists - const { data: existing } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .maybeSingle(); - - - if (existing) { - // Update existing - const currentSettings = (existing.settings as Record) || {}; - const currentApiKeys = (currentSettings.api_keys as Record) || {}; - - const newSettings = { - ...currentSettings, - api_keys: { ...currentApiKeys, ...secrets } - }; - - const { error } = await supabase - .from('user_secrets') - .update({ settings: newSettings }) - .eq('user_id', userId); - - if (error) throw error; - } else { - // Insert new - const { error } = await supabase - .from('user_secrets') - .insert({ - user_id: userId, - settings: { api_keys: secrets } - }); - - if (error) throw error; - } - } catch (error) { - console.error('Error updating user secrets:', error); - throw error; - } -}; - diff --git a/packages/ui/src/components/ImageWizard/handlers/dataHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/dataHandlers.ts index f503a75c..0b9e0fba 100644 --- a/packages/ui/src/components/ImageWizard/handlers/dataHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/dataHandlers.ts @@ -1,16 +1,11 @@ import { ImageFile } from '../types'; -import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { translate } from '@/i18n'; import { Logger } from '../utils/logger'; +import { fetchPictureById, fetchVersions, fetchRecentPictures } from '@/modules/posts/client-pictures'; /** * Data Loading & Saving Handlers - * - Load/save presets - * - Load/save workflows - * - Load/save templates - * - Load/save quick actions - * - Load/save history * - Load family versions * - Load available images */ @@ -29,77 +24,44 @@ export const loadFamilyVersions = async ( return; } - // For each image, find its complete family tree + // For each image, find its complete family tree via API const allFamilyImages = new Set(); + const allVersionData: any[] = []; for (const imageId of imageIds) { // First, get the current image details to check if it has a parent - const { data: currentImage, error: currentError } = await supabase - .from('pictures') - .select('id, parent_id') - .eq('id', imageId) - .single(); + const currentImage = await fetchPictureById(imageId); - if (currentError) { - console.error('🔧 [Wizard] Error loading current image:', currentError); + if (!currentImage) { + console.error('🔧 [Wizard] Error loading current image:', imageId); continue; } + // Load all versions in this family via API + const familyVersions = await fetchVersions(currentImage as any); - - // Determine the root of the family tree - let rootId = currentImage.parent_id || imageId; - - // Load all versions in this family (root + all children) - const { data: familyVersions, error: familyError } = await supabase - .from('pictures') - .select(` - id, - title, - image_url, - user_id, - parent_id, - description, - is_selected, - created_at - `) - .or(`id.eq.${rootId},parent_id.eq.${rootId}`) - .order('created_at', { ascending: true }); - - if (familyError) { - console.error('🔧 [Wizard] Error loading family versions:', familyError); + if (!familyVersions || !Array.isArray(familyVersions)) { + console.error('🔧 [Wizard] Error loading family versions for:', imageId); continue; } - - // Add all family members to our set (excluding the initial image) - familyVersions?.forEach(version => { + familyVersions.forEach((version: any) => { if (version.id !== imageId) { allFamilyImages.add(version.id); + allVersionData.push(version); } }); } - // Now fetch all the unique family images + + // Map to ImageFile format and update state if (allFamilyImages.size > 0) { - const { data: versions, error } = await supabase - .from('pictures') - .select(` - id, - title, - image_url, - user_id, - parent_id, - description, - is_selected, - created_at - `) - .in('id', Array.from(allFamilyImages)) - .order('created_at', { ascending: true }); + // Deduplicate by id + const uniqueVersions = new Map(); + allVersionData.forEach(v => uniqueVersions.set(v.id, v)); + const versions = Array.from(uniqueVersions.values()); - if (error) throw error; - - const versionImages: ImageFile[] = versions?.map(version => ({ + const versionImages: ImageFile[] = versions.map(version => ({ id: version.id, src: version.image_url, title: version.title, @@ -108,9 +70,7 @@ export const loadFamilyVersions = async ( isPreferred: version.is_selected || false, isGenerated: true, aiText: version.description || undefined - })) || []; - - + })); // Add versions to images, but also update existing images with correct selection status setImages(prev => { @@ -118,9 +78,9 @@ export const loadFamilyVersions = async ( const dbSelectionStatus = new Map(); // Check if this is a singleton family (only one version) - const isSingleton = versions?.length === 1; + const isSingleton = versions.length === 1; - versions?.forEach(version => { + versions.forEach(version => { // If explicitly selected in DB, OR if it's the only version in the family const isPreferred = version.is_selected || (isSingleton && !version.parent_id); dbSelectionStatus.set(version.id, isPreferred); @@ -160,27 +120,16 @@ export const loadAvailableImages = async ( ) => { setLoadingImages(true); try { - // Load images from all users (public images) - const { data: pictures, error } = await supabase - .from('pictures') - .select(` - id, - title, - image_url, - user_id - `) - .order('created_at', { ascending: false }) - .limit(50); + // Load recent pictures via API + const pictures = await fetchRecentPictures(50); - if (error) throw error; - - const imageFiles: ImageFile[] = pictures?.map(picture => ({ + const imageFiles: ImageFile[] = pictures.map((picture: any) => ({ id: picture.id, src: picture.image_url, title: picture.title, userId: picture.user_id, selected: false - })) || []; + })); setAvailableImages(imageFiles); } catch (error) { @@ -190,4 +139,3 @@ export const loadAvailableImages = async ( setLoadingImages(false); } }; - diff --git a/packages/ui/src/components/ImageWizard/handlers/imageHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/imageHandlers.ts index 83499f70..5eb0af52 100644 --- a/packages/ui/src/components/ImageWizard/handlers/imageHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/imageHandlers.ts @@ -1,8 +1,15 @@ import { ImageFile } from '../types'; -import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { translate } from '@/i18n'; import { downloadImage, generateFilename } from '@/utils/downloadUtils'; +import { + fetchPictureById, + deletePictures, + updatePicture, + createPicture, + uploadFileToStorage, +} from '@/modules/posts/client-pictures'; +import { unselectImageFamily } from '../db'; /** * Image Handlers @@ -63,7 +70,6 @@ export const handleFileUpload = async ( realDatabaseId: data.dbId, uploadStatus: 'ready', src: data.thumbnailUrl || '', // Use generated thumbnail - // aiText: JSON.stringify(data.meta || {}) // Don't store meta as description } : img )); toast.success(translate('Video uploaded successfully')); @@ -148,86 +154,24 @@ export const confirmDeleteImage = async ( // Check if this image exists in the database and delete it try { - const { data: picture, error: fetchError } = await supabase - .from('pictures') - .select('id, image_url, user_id, parent_id') - .eq('id', imageToDelete) - .single(); + const picture = await fetchPictureById(imageToDelete); - if (!fetchError && picture) { - // Check if user owns this image - const { data: { user } } = await supabase.auth.getUser(); - if (user && picture.user_id === user.id) { + if (picture) { + // Delete via API (handles storage cleanup and children internally) + await deletePictures([imageToDelete]); - let isRootWithChildren = false; + toast.success(translate('Image deleted successfully')); - // If this is a root image (parent_id is null), check for children - if (!picture.parent_id) { - // Check if this root image has any children - const { data: children, error: childrenError } = await supabase - .from('pictures') - .select('id') - .eq('parent_id', imageToDelete); + // If we deleted a root image, remove all related images from local state + if (!picture.parent_id) { + setImages(prev => prev.filter(img => + img.id !== imageToDelete && + img.realDatabaseId !== imageToDelete + )); - if (!childrenError && children && children.length > 0) { - // This is a root image with children - delete entire family - console.log('Deleting root image with children - removing entire family'); - isRootWithChildren = true; - - // Delete all children first - const { error: deleteChildrenError } = await supabase - .from('pictures') - .delete() - .eq('parent_id', imageToDelete); - - if (deleteChildrenError) { - console.error('Error deleting children:', deleteChildrenError); - toast.error(translate('Failed to delete image family')); - return; - } - } - } - - // Delete the main image from database - const { error: deleteError } = await supabase - .from('pictures') - .delete() - .eq('id', imageToDelete); - - if (deleteError) { - console.error('Error deleting picture from database:', deleteError); - toast.error(translate('Failed to delete image from database')); - } else { - // Try to delete from storage as well - if (picture.image_url) { - const urlParts = picture.image_url.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const userIdFromUrl = urlParts[urlParts.length - 2]; - - const { error: storageError } = await supabase.storage - .from('pictures') - .remove([`${userIdFromUrl}/${fileName}`]); - - if (storageError) { - console.error('Error deleting from storage:', storageError); - // Don't show error to user as the main deletion succeeded - } - } - - toast.success(translate(isRootWithChildren ? 'Image family deleted successfully' : 'Image deleted successfully')); - - // If we deleted a root image, remove all related images from local state - if (!picture.parent_id) { - setImages(prev => prev.filter(img => - img.id !== imageToDelete && - img.realDatabaseId !== imageToDelete - )); - - // Reload family versions to refresh the UI - if (initialImages.length > 0) { - setTimeout(() => loadFamilyVersions(initialImages), 500); - } - } + // Reload family versions to refresh the UI + if (initialImages.length > 0) { + setTimeout(() => loadFamilyVersions(initialImages), 500); } } } @@ -252,13 +196,6 @@ export const setAsSelected = async ( const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const isSaved = uuidRegex.test(imageId); - // Get the current user - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { - toast.error(translate('You must be logged in to set version as selected')); - return; - } - // Handle Unsaved Generated Images if (!isSaved) { const img = images.find(i => i.id === imageId); @@ -269,37 +206,28 @@ export const setAsSelected = async ( try { // 1. Upload to Storage const blob = await (await fetch(img.src)).blob(); - const fileName = `${user.id}/${Date.now()}_generated.png`; - const { error: uploadError } = await supabase.storage.from('pictures').upload(fileName, blob); - if (uploadError) throw uploadError; - - const { data: { publicUrl } } = supabase.storage.from('pictures').getPublicUrl(fileName); + const fileName = `${Date.now()}_generated.png`; + const file = new File([blob], fileName, { type: 'image/png' }); + // uploadFileToStorage prepends userId internally + const publicUrl = await uploadFileToStorage(img.userId || '', file, `${img.userId}/${fileName}`); // 2. Unselect previous selected in family (if parent exists) const parentId = img.parentForNewVersions || null; if (parentId) { - await supabase - .from('pictures') - .update({ is_selected: false }) - .or(`id.eq.${parentId},parent_id.eq.${parentId}`) - .eq('user_id', user.id); + await unselectImageFamily(parentId, img.userId || ''); } - // 3. Insert new picture as selected - const { data: newPic, error: insertError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - image_url: publicUrl, - parent_id: parentId, - is_selected: true, - title: img.title, - description: img.aiText - }) - .select() - .single(); + // 3. Insert new picture as selected via API + const newPic = await createPicture({ + user_id: img.userId, + image_url: publicUrl, + parent_id: parentId, + is_selected: true, + title: img.title, + description: img.aiText, + } as any); - if (insertError) throw insertError; + if (!newPic) throw new Error('Failed to create picture'); toast.dismiss(); toast.success(translate('Version saved and selected')); @@ -313,20 +241,17 @@ export const setAsSelected = async ( ...item, id: newPic.id, realDatabaseId: newPic.id, - selected: true, // Keep it selected in UI as we just acted on it - isPreferred: true // Mark as preferred in DB context + selected: true, + isPreferred: true }; } return { ...item, selected: false, - isPreferred: item.isPreferred // Don't change preference for unrelated images? - // Wait, if it's a new family (new generated image), usually the older one wasn't preferred? - // Logic below handles unselecting previous family members. + isPreferred: item.isPreferred }; }).map(item => { - // For newly generated images, if we find a parent, we need to un-prefer it? - // The logic at line 280 handled DB update. We need local update. + // Un-prefer parent if applicable if (img.parentForNewVersions && (item.id === img.parentForNewVersions || item.realDatabaseId === img.parentForNewVersions)) { return { ...item, isPreferred: false }; } @@ -349,62 +274,30 @@ export const setAsSelected = async ( } } - // Get the image and its parent/child relationships - const { data: targetImage, error: fetchError } = await supabase - .from('pictures') - .select('id, parent_id, user_id') - .eq('id', imageId) - .single(); + // Saved image — fetch from API + const targetImage = await fetchPictureById(imageId); - if (fetchError) { - console.error('Error fetching image:', fetchError); + if (!targetImage) { toast.error(translate('Failed to find image')); return; } - // Check if user owns this image - if (targetImage.user_id !== user.id) { - toast.error(translate('Can only select your own images')); - return; - } - // Find all related images (same parent tree) const parentId = targetImage.parent_id || imageId; // First, unselect all images in the same family - const { error: unselectError } = await supabase - .from('pictures') - .update({ is_selected: false }) - .or(`id.eq.${parentId},parent_id.eq.${parentId}`) - .eq('user_id', user.id); - - if (unselectError) { - console.error('Error unselecting images:', unselectError); - toast.error(translate('Failed to update selection')); - return; - } + await unselectImageFamily(parentId, targetImage.user_id); // Then select the target image - const { error: selectError } = await supabase - .from('pictures') - .update({ is_selected: true }) - .eq('id', imageId); - - if (selectError) { - console.error('Error selecting image:', selectError); - toast.error(translate('Failed to set as selected')); - return; - } + await updatePicture(imageId, { is_selected: true } as any); toast.success(translate('Version set as selected')); - setImages(prev => { const updated = prev.map(img => { // If this is the image we just selected, mark it as preferred if (img.id === imageId) { - console.log('🔧 [setAsSelected] Setting as preferred:', img.title); - return { ...img, isPreferred: true }; // Don't enforce UI selection here if not needed, but usually we engage with it. + return { ...img, isPreferred: true }; } // Find the selected image to determine family relationships @@ -414,14 +307,12 @@ export const setAsSelected = async ( // If this image is in the same family, un-prefer it if (selectedRootId && currentRootId && selectedRootId === currentRootId && img.id !== imageId) { - console.log('🔧 [setAsSelected] Unsetting preference for family member:', img.title); return { ...img, isPreferred: false }; } return img; }); - console.log('🔧 [setAsSelected] Images after update:', updated.map(img => ({ id: img.id, title: img.title, selected: img.selected }))); return updated; }); diff --git a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts index e003cb55..ffa870f2 100644 --- a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts @@ -1,7 +1,7 @@ import { ImageFile } from '../types'; import { uploadImage } from '@/lib/uploadUtils'; -import { supabase } from '@/integrations/supabase/client'; -import { createPost, updatePostDetails } from '@/lib/db'; +import { createPost, updatePostDetails } from '@/modules/posts/client-posts'; +import { createPicture, updatePicture } from '@/modules/posts/client-pictures'; import { toast } from 'sonner'; import { translate } from '@/i18n'; @@ -72,11 +72,6 @@ export const publishImage = async ( settings: settings, meta: meta }); - // updated_at is handled by server or trigger usually, or we can add it to api if needed but usually standard fields are auto. - // API currently doesn't key `updated_at` from body, but `db-posts.ts` doesn't set it explicitly either. - // Usually supabase sets it via trigger or we should set it. - // The old code set it: `updated_at: new Date().toISOString()`. - // The API `handleUpdatePost` does `update({ ... })`. } else { // Create new post const response = await createPost({ @@ -98,94 +93,45 @@ export const publishImage = async ( // If image already exists in DB (has realDatabaseId), just update its metadata/position if (img.realDatabaseId) { - const { error: updateImgError } = await supabase - .from('pictures') - .update({ + try { + await updatePicture(img.realDatabaseId, { title: img.title || title, description: img.description || img.aiText || null, position: i, - post_id: postId // Ensure it's linked to this post (re-linking if moved) - }) - .eq('id', img.realDatabaseId); - - if (updateImgError) { - console.error(`Failed to update image ${i}:`, updateImgError); - } else { + post_id: postId + } as any); successfulOps++; + } catch (err) { + console.error(`Failed to update image ${i}:`, err); } continue; } // New Media Upload Logic let file: File; - let isVideo = img.type === 'video'; - - if (isVideo) { - if (img.uploadStatus !== 'ready' || !img.muxAssetId) { - console.error(`Video ${i} is not ready or missing Mux ID`); - toast.error(translate(`Video "${img.title}" is not ready yet`)); - continue; - } - - // Insert Mux Video - const metadata = { - mux_upload_id: img.muxUploadId, - mux_asset_id: img.muxAssetId, - mux_playback_id: img.muxPlaybackId, - // duration, aspect_ratio etc would need to be fetched or assumed available later via webhook/polling - // For now we store minimal required meta - created_at: new Date().toISOString(), - status: 'ready' - }; - - const { error: insertError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - post_id: postId, - title: img.title || title, - description: img.description || img.aiText || null, - image_url: `https://stream.mux.com/${img.muxPlaybackId}.m3u8`, // HLS URL - thumbnail_url: `https://image.mux.com/${img.muxPlaybackId}/thumbnail.jpg`, - position: i, - type: 'mux-video', - meta: metadata - }); - - if (insertError) { - console.error(`Failed to link video ${i} to post:`, insertError); - } else { - successfulOps++; - } - continue; // Done with video - } // External Page Logic (Link Post) if (img.type === 'page-external') { const siteInfo = img.meta || {}; - // Ensure we have the link URL if missing in meta but present in path if (!siteInfo.url && img.path) { siteInfo.url = img.path; } - const { error: insertError } = await supabase - .from('pictures') - .insert({ + try { + await createPicture({ user_id: user.id, post_id: postId, title: img.title || title, description: img.description || null, - image_url: img.src, // Use external cover image URL + image_url: img.src, position: i, type: 'page-external', - meta: siteInfo // Store full site info in picture.meta - }); - - if (insertError) { - console.error("Failed to insert page-external picture:", insertError); - toast.error(translate("Failed to save link")); - } else { + meta: siteInfo + } as any); successfulOps++; + } catch (err) { + console.error("Failed to insert page-external picture:", err); + toast.error(translate("Failed to save link")); } continue; } @@ -204,12 +150,11 @@ export const publishImage = async ( } } - // Upload to Supabase Storage (direct or via proxy) + // Upload to Storage const { publicUrl, meta: uploadedMeta } = await uploadImage(file, user.id); - const { error: insertError } = await supabase - .from('pictures') - .insert({ + try { + await createPicture({ user_id: user.id, post_id: postId, title: img.title || title, @@ -218,12 +163,10 @@ export const publishImage = async ( position: i, type: 'supabase-image', meta: uploadedMeta || {} - }); - - if (insertError) { - console.error(`Failed to link image ${i} to post:`, insertError); - } else { + } as any); successfulOps++; + } catch (err) { + console.error(`Failed to link image ${i} to post:`, err); } } @@ -234,11 +177,6 @@ export const publishImage = async ( toast.success(translate(editingPostId ? 'Post updated successfully!' : 'Post published successfully!')); if (imagesToPublish.length > 0) { - // Pass the postId as the second argument (repurposing prompt likely, or we should update signature) - // Actually, let's keep the signature but pass the ID. - // Wait, onPublish signature is (imageUrl: string, prompt: string) => void - // We should probably change the signature or standard usage. - // Given the request, let's pass postId as the second arg if mode is post. onPublish?.(imagesToPublish[0].src, postId); } @@ -254,7 +192,7 @@ export const quickPublishAsNew = async ( options: PublishImageOptions, setIsPublishing: React.Dispatch> ) => { - const { user, generatedImage, images, lightboxOpen, currentImageIndex, postTitle, prompt, isOrgContext, orgSlug, onPublish } = options; + const { user, generatedImage, images, lightboxOpen, currentImageIndex, postTitle, prompt, onPublish } = options; if (!user) { toast.error(translate('User not authenticated')); @@ -317,36 +255,20 @@ export const quickPublishAsNew = async ( file = new File([blob], `quick-publish-${Date.now()}.png`, { type: 'image/png' }); } - // Upload to Supabase Storage (direct or via proxy) + // Upload to Storage const { publicUrl, meta: uploadedMeta } = await uploadImage(file, user.id); - // Get organization ID if in org context - let organizationId = null; - if (isOrgContext && orgSlug) { - const { data: org } = await supabase - .from('organizations') - .select('id') - .eq('slug', orgSlug) - .single(); - organizationId = org?.id || null; - } - // Insert into pictures linked to Post - const { error: insertError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - post_id: postId, // Link to Post - title: imageTitle || 'Quick Publish', - description: prompt.trim(), - image_url: publicUrl, - organization_id: organizationId, - position: 0, - type: 'supabase-image', - meta: uploadedMeta || {} - }); - - if (insertError) throw insertError; + await createPicture({ + user_id: user.id, + post_id: postId, + title: imageTitle || 'Quick Publish', + description: prompt.trim(), + image_url: publicUrl, + position: 0, + type: 'supabase-image', + meta: uploadedMeta || {} + } as any); toast.success(translate('Image quick published as new post!')); onPublish?.(publicUrl, prompt); @@ -362,7 +284,7 @@ export const publishToGallery = async ( options: PublishImageOptions, setIsPublishing: React.Dispatch> ) => { - const { user, images, onPublish, isOrgContext, orgSlug } = options; + const { user, images, onPublish } = options; if (!user) { toast.error(translate('User not authenticated')); @@ -392,45 +314,6 @@ export const publishToGallery = async ( // New Media Upload Logic let file: File; - let isVideo = img.type === 'video'; - - if (isVideo) { - if (img.uploadStatus !== 'ready' || !img.muxAssetId) { - console.error(`Video ${i} is not ready or missing Mux ID`); - toast.error(translate(`Video "${img.title}" is not ready yet`)); - continue; - } - - // Insert Mux Video - const metadata = { - mux_upload_id: img.muxUploadId, - mux_asset_id: img.muxAssetId, - mux_playback_id: img.muxPlaybackId, - created_at: new Date().toISOString(), - status: 'ready' - }; - - const { error: insertError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - post_id: null, // No post ID - title: img.title || 'Untitled Video', - description: img.description || img.aiText || null, - image_url: `https://stream.mux.com/${img.muxPlaybackId}.m3u8`, - thumbnail_url: `https://image.mux.com/${img.muxPlaybackId}/thumbnail.jpg`, - position: 0, // No specific position in gallery - type: 'mux-video', - meta: metadata - }); - - if (insertError) { - console.error(`Failed to save video ${i}:`, insertError); - } else { - successfulOps++; - } - continue; - } // External Page Logic if (img.type === 'page-external') { @@ -439,9 +322,8 @@ export const publishToGallery = async ( siteInfo.url = img.path; } - const { error: insertError } = await supabase - .from('pictures') - .insert({ + try { + await createPicture({ user_id: user.id, post_id: null, title: img.title || 'Untitled Link', @@ -450,12 +332,10 @@ export const publishToGallery = async ( position: 0, type: 'page-external', meta: siteInfo - }); - - if (insertError) { - console.error("Failed to save page-external picture:", insertError); - } else { + } as any); successfulOps++; + } catch (err) { + console.error("Failed to save page-external picture:", err); } continue; } @@ -474,12 +354,11 @@ export const publishToGallery = async ( } } - // Upload to Supabase Storage + // Upload to Storage const { publicUrl, meta: uploadedMeta } = await uploadImage(file, user.id); - const { error: insertError } = await supabase - .from('pictures') - .insert({ + try { + await createPicture({ user_id: user.id, post_id: null, title: img.title || 'Untitled Image', @@ -488,12 +367,10 @@ export const publishToGallery = async ( position: 0, type: 'supabase-image', meta: uploadedMeta || {} - }); - - if (insertError) { - console.error(`Failed to save image ${i}:`, insertError); - } else { + } as any); successfulOps++; + } catch (err) { + console.error(`Failed to save image ${i}:`, err); } } diff --git a/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts index 480d03ee..5d05ded5 100644 --- a/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts @@ -3,7 +3,7 @@ import { translate } from '@/i18n'; import { QuickAction } from '@/constants'; import { PromptPreset } from '@/components/PresetManager'; import { Workflow } from '@/components/WorkflowManager'; -import { getUserSettings, updateUserSettings } from '@/lib/db'; +import { getUserSettings, updateUserSettings } from '@/modules/user/client-user'; /** * Settings Handlers diff --git a/packages/ui/src/components/ImageWizard/handlers/voiceHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/voiceHandlers.ts index 1c4464ec..0af67026 100644 --- a/packages/ui/src/components/ImageWizard/handlers/voiceHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/voiceHandlers.ts @@ -1,10 +1,10 @@ import { ImageFile } from '../types'; -import { getUserOpenAIKey } from '../db'; import { toast } from 'sonner'; import { translate } from '@/i18n'; import { transcribeAudio, runTools } from '@/lib/openai'; import { PromptPreset } from '@/components/PresetManager'; import { Logger } from '../utils/logger'; +import { getUserOpenAIKey } from '@/modules/user/client-user'; /** * Voice Handlers diff --git a/packages/ui/src/components/MarkdownEditor.tsx b/packages/ui/src/components/MarkdownEditor.tsx index 402858db..01568b9d 100644 --- a/packages/ui/src/components/MarkdownEditor.tsx +++ b/packages/ui/src/components/MarkdownEditor.tsx @@ -1,10 +1,6 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useRef, useState, useCallback } from 'react'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; -import { supabase } from '@/integrations/supabase/client'; - - -// Lazy load the heavy editor component -//const MilkdownEditorInternal = React.lazy(() => import('@/components/lazy-editors/MilkdownEdito')); +import { fetchPictureById } from '@/modules/posts/client-pictures'; interface MarkdownEditorProps { value: string; @@ -25,28 +21,12 @@ const MarkdownEditor: React.FC = ({ const [imagePickerOpen, setImagePickerOpen] = useState(false); const pendingImageResolveRef = useRef<((url: string) => void) | null>(null); - // Handler for image upload - opens the ImagePickerDialog - const handleImageUpload = useCallback((_file?: File): Promise => { - console.log('[handleImageUpload] Called from image-block, opening ImagePickerDialog'); - return new Promise((resolve) => { - pendingImageResolveRef.current = resolve; - console.log('[handleImageUpload] Resolve function stored in ref'); - setImagePickerOpen(true); - }); - }, []); - // Handler for image selection from picker const handleImageSelect = useCallback(async (pictureId: string) => { - console.log('[handleImageSelect] Selected picture ID:', pictureId); try { - // Fetch the image URL from Supabase - const { data, error } = await supabase - .from('pictures') - .select('image_url') - .eq('id', pictureId) - .single(); - - if (error) throw error; + // Fetch the image URL via API + const data = await fetchPictureById(pictureId); + if (!data) throw new Error('Picture not found'); const imageUrl = data.image_url; @@ -59,7 +39,6 @@ const MarkdownEditor: React.FC = ({ setImagePickerOpen(false); } catch (error) { - console.error('[handleImageSelect] Error fetching image:', error); if (pendingImageResolveRef.current) { pendingImageResolveRef.current(''); pendingImageResolveRef.current = null; diff --git a/packages/ui/src/components/MarkdownEditorEx.tsx b/packages/ui/src/components/MarkdownEditorEx.tsx index 4ffb6e80..eb986e14 100644 --- a/packages/ui/src/components/MarkdownEditorEx.tsx +++ b/packages/ui/src/components/MarkdownEditorEx.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useCallback, useEffect } from 'react'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; -import { supabase } from '@/integrations/supabase/client'; +import { fetchPictureById } from '@/modules/posts/client-pictures'; // Lazy load the heavy editor component @@ -111,14 +111,9 @@ const MarkdownEditor: React.FC = ({ const handleImageSelect = useCallback(async (pictureId: string) => { try { - // Fetch the image URL from Supabase - const { data, error } = await supabase - .from('pictures') - .select('image_url') - .eq('id', pictureId) - .single(); - - if (error) throw error; + // Fetch the image URL via API + const data = await fetchPictureById(pictureId); + if (!data) throw new Error('Picture not found'); const imageUrl = data.image_url; diff --git a/packages/ui/src/components/PhotoCard.tsx b/packages/ui/src/components/PhotoCard.tsx index 6ea8305f..7df09838 100644 --- a/packages/ui/src/components/PhotoCard.tsx +++ b/packages/ui/src/components/PhotoCard.tsx @@ -1,6 +1,5 @@ import { Heart, Download, Share2, User, MessageCircle, Edit3, Trash2, Maximize, Layers, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { useState, useEffect } from "react"; @@ -16,6 +15,7 @@ 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; @@ -118,27 +118,9 @@ const PhotoCard = ({ } try { - if (localIsLiked) { - // Unlike - const { error } = await supabase - .from('likes') - .delete() - .eq('user_id', user.id) - .eq('picture_id', pictureId); - - if (error) throw error; - setLocalIsLiked(false); - setLocalLikes(prev => prev - 1); - } else { - // Like - const { error } = await supabase - .from('likes') - .insert([{ user_id: user.id, picture_id: pictureId }]); - - if (error) throw error; - setLocalIsLiked(true); - setLocalLikes(prev => prev + 1); - } + const nowLiked = await toggleLike(user.id, pictureId, localIsLiked); + setLocalIsLiked(nowLiked); + setLocalLikes(prev => nowLiked ? prev + 1 : prev - 1); onLike?.(); } catch (error) { @@ -162,38 +144,7 @@ const PhotoCard = ({ setIsDeleting(true); try { - // First get the picture details for storage cleanup - const { data: picture, error: fetchError } = await supabase - .from('pictures') - .select('image_url') - .eq('id', pictureId) - .single(); - - if (fetchError) throw fetchError; - - // Delete from database (this will cascade delete likes and comments due to foreign keys) - const { error: deleteError } = await supabase - .from('pictures') - .delete() - .eq('id', pictureId); - - if (deleteError) throw deleteError; - - // Try to delete from storage as well - if (picture?.image_url) { - const urlParts = picture.image_url.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const userIdFromUrl = urlParts[urlParts.length - 2]; - - const { error: storageError } = await supabase.storage - .from('pictures') - .remove([`${userIdFromUrl}/${fileName}`]); - - if (storageError) { - console.error('Error deleting from storage:', storageError); - // Don't show error to user as the main deletion succeeded - } - } + await deletePicture(pictureId); toast.success(translate('Image deleted successfully')); onDelete?.(); // Trigger refresh of the parent component @@ -325,22 +276,13 @@ const PhotoCard = ({ if (option === 'overwrite') { // Overwrite the existing image - // First, get the current image URL to extract the file path const currentImageUrl = image; if (currentImageUrl.includes('supabase.co/storage/')) { const urlParts = currentImageUrl.split('/'); const fileName = urlParts[urlParts.length - 1]; const bucketPath = `${authorId}/${fileName}`; - // Upload new image with same path (overwrite) - const { error: uploadError } = await supabase.storage - .from('pictures') - .update(bucketPath, blob, { - cacheControl: '0', // Disable caching to ensure immediate update - upsert: true - }); - - if (uploadError) throw uploadError; + await updateStorageFile(bucketPath, blob); toast.success(translate('Image updated successfully!')); } else { @@ -349,29 +291,14 @@ const PhotoCard = ({ } } else { // Create new image - const fileName = `${user.id}/${Date.now()}-generated.png`; - const { data: uploadData, error: uploadError } = await supabase.storage - .from('pictures') - .upload(fileName, blob); + const publicUrl = await uploadFileToStorage(user.id, blob); - if (uploadError) throw uploadError; - - // Get public URL - const { data: { publicUrl } } = supabase.storage - .from('pictures') - .getPublicUrl(fileName); - - // Save to database - const { error: dbError } = await supabase - .from('pictures') - .insert([{ - title: newTitle, - description: description || translate('Generated from') + `: ${title}`, - image_url: publicUrl, - user_id: user.id - }]); - - if (dbError) throw dbError; + 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!')); } diff --git a/packages/ui/src/components/PhotoGrid.tsx b/packages/ui/src/components/PhotoGrid.tsx index f3095c57..c0209316 100644 --- a/packages/ui/src/components/PhotoGrid.tsx +++ b/packages/ui/src/components/PhotoGrid.tsx @@ -38,6 +38,7 @@ export interface MediaItemType { } import type { FeedSortOption } from '@/hooks/useFeedData'; +import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts"; interface MediaGridProps { @@ -135,7 +136,7 @@ const MediaGrid = ({ setLoading(customLoading || false); } else { // Map FeedPost[] -> MediaItemType[] - finalMedia = db.mapFeedPostsToMediaItems(feedPosts as any, sortBy); + finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy); setLoading(feedLoading); } diff --git a/packages/ui/src/components/UploadModal.tsx b/packages/ui/src/components/UploadModal.tsx index 4af813c7..aea65f4f 100644 --- a/packages/ui/src/components/UploadModal.tsx +++ b/packages/ui/src/components/UploadModal.tsx @@ -7,6 +7,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { supabase } from '@/integrations/supabase/client'; +import { createPicture } from '@/modules/posts/client-pictures'; import { useAuth } from '@/hooks/useAuth'; import { useToast } from '@/hooks/use-toast'; import { Upload, X } from 'lucide-react'; @@ -61,29 +62,14 @@ const UploadModal = ({ open, onOpenChange, onUploadSuccess }: UploadModalProps) // Upload file to storage (direct or via proxy) const { publicUrl } = await uploadImage(file, user.id); - // Get organization ID if in org context - let organizationId = null; - if (isOrgContext && orgSlug) { - const { data: org } = await supabase - .from('organizations') - .select('id') - .eq('slug', orgSlug) - .single(); - organizationId = org?.id || null; - } - // Save picture metadata to database - const { error: dbError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - title: data.title?.trim() || null, - description: data.description || null, - image_url: publicUrl, - organization_id: organizationId, - }); - - if (dbError) throw dbError; + // Save picture metadata via API + await createPicture({ + user_id: user.id, + title: data.title?.trim() || null, + description: data.description || null, + image_url: publicUrl + }); toast({ title: "Picture uploaded successfully!", diff --git a/packages/ui/src/components/UserPictures.tsx b/packages/ui/src/components/UserPictures.tsx index f2e9831c..ea6a53ff 100644 --- a/packages/ui/src/components/UserPictures.tsx +++ b/packages/ui/src/components/UserPictures.tsx @@ -1,6 +1,9 @@ import { useState, useEffect } from "react"; import { supabase } from "@/integrations/supabase/client"; +import { fetchUserPictures as fetchUserPicturesAPI } from "@/modules/posts/client-pictures"; +import { deletePicture } from "@/modules/posts/client-pictures"; +import { deletePost } from "@/modules/posts/client-posts"; import { MediaItem } from "@/types"; import { normalizeMediaType, isVideoType, detectMediaType } from "@/lib/mediaRegistry"; import { T, translate } from "@/i18n"; @@ -40,16 +43,8 @@ const UserPictures = ({ userId, isOwner }: UserPicturesProps) => { try { if (showLoading) setLoading(true); - // 1. Fetch all pictures for the user - const { data: picturesData, error: picturesError } = await supabase - .from('pictures') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }); - - if (picturesError) throw picturesError; - - const pictures = (picturesData || []) as MediaItem[]; + // 1. Fetch all pictures for the user via API + const pictures = await fetchUserPicturesAPI(userId) as MediaItem[]; // 2. Fetch all posts for the user to get titles const { data: postsData, error: postsError } = await supabase @@ -122,30 +117,11 @@ const UserPictures = ({ userId, isOwner }: UserPicturesProps) => { try { if (itemToDelete.type === 'post') { - const { error } = await supabase - .from('posts') - .delete() - .eq('id', itemToDelete.id); - - if (error) throw error; + await deletePost(itemToDelete.id); toast.success(translate("Post deleted successfully")); } else { - // Delete picture - const { error } = await supabase - .from('pictures') - .delete() - .eq('id', itemToDelete.id); - - if (error) throw error; - - // Ideally we check if we should delete from storage too, similar to Profile logic - // For now we trust triggers or standard behavior. - // The Profile implementation manually deletes from storage. - // To match that: - // We would need to fetch the picture first to get the URL, but here we can just do the DB delete - // as this is what was requested and storage cleanup often handled separately or via another call. - // Given "remove pictures" simple request, DB delete is the primary action. - + // Delete picture via API (handles storage + db) + await deletePicture(itemToDelete.id); toast.success(translate("Picture deleted successfully")); } diff --git a/packages/ui/src/components/VersionSelector.tsx b/packages/ui/src/components/VersionSelector.tsx index 1f072ae0..80505feb 100644 --- a/packages/ui/src/components/VersionSelector.tsx +++ b/packages/ui/src/components/VersionSelector.tsx @@ -3,7 +3,7 @@ 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 { supabase } from '@/integrations/supabase/client'; +import { fetchPictureById, fetchVersions, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures'; import { useAuth } from '@/hooks/useAuth'; import { toast } from 'sonner'; import { T, translate } from '@/i18n'; @@ -57,26 +57,11 @@ const VersionSelector: React.FC = ({ setLoading(true); try { // Get the current picture to determine if it's a parent or child - const { data: currentPicture, error: currentError } = await supabase - .from('pictures') - .select('id, parent_id, title, image_url, is_selected, created_at, visible') - .eq('id', currentPictureId) - .single(); + const currentPicture = await fetchPictureById(currentPictureId); + if (!currentPicture) throw new Error('Picture not found'); - if (currentError) throw currentError; - - // Determine the root parent ID - const rootParentId = currentPicture.parent_id || currentPicture.id; - - // Get all versions (parent + children) for this image tree - const { data: allVersions, error: versionsError } = await supabase - .from('pictures') - .select('id, title, image_url, is_selected, created_at, parent_id, visible, user_id') - .eq('user_id', user.id) - .or(`id.eq.${rootParentId},parent_id.eq.${rootParentId}`) - .order('created_at', { ascending: true }); - - if (versionsError) throw versionsError; + // Fetch all versions via API + const allVersions = await fetchVersions(currentPicture, user.id); setVersions(allVersions || []); } catch (error) { @@ -92,23 +77,10 @@ const VersionSelector: React.FC = ({ setUpdating(versionId); try { - // First, unselect all versions in this image tree - const rootParentId = versions.find(v => v.parent_id === null)?.id || currentPictureId; - - await supabase - .from('pictures') - .update({ is_selected: false }) - .eq('user_id', user.id) - .or(`id.eq.${rootParentId},parent_id.eq.${rootParentId}`); - - // Then select the chosen version - const { error: selectError } = await supabase - .from('pictures') - .update({ is_selected: true }) - .eq('id', versionId) - .eq('user_id', user.id); - - if (selectError) throw selectError; + // Unselect all versions in this image tree, then select the chosen one + await Promise.all( + versions.map(v => updatePicture(v.id, { is_selected: v.id === versionId } as any)) + ); // Update local state setVersions(prevVersions => @@ -133,13 +105,7 @@ const VersionSelector: React.FC = ({ setToggling(versionId); try { - const { error } = await supabase - .from('pictures') - .update({ visible: !currentVisibility }) - .eq('id', versionId) - .eq('user_id', user.id); - - if (error) throw error; + await updatePicture(versionId, { visible: !currentVisibility } as any); // Update local state setVersions(prevVersions => @@ -169,17 +135,12 @@ const VersionSelector: React.FC = ({ setIsDeleting(true); try { // 1. Find all descendants to delete (cascade) - const { data: allUserPictures, error: loadError } = await supabase - .from('pictures') - .select('*') - .eq('user_id', user.id); // Fetch all to build tree (optimization: could just fetch relevant sub-tree but this is safer) + const allUserPictures = await fetchUserPictures(user.id); - if (loadError) throw loadError; - - const findDescendants = (parentId: string): typeof versionToDelete[] => { - const descendants: typeof versionToDelete[] = []; - const children = allUserPictures.filter(p => p.parent_id === parentId); - children.forEach(child => { + 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)); }); @@ -188,30 +149,13 @@ const VersionSelector: React.FC = ({ const descendantsToDelete = findDescendants(versionToDelete.id); const allToDelete = [versionToDelete, ...descendantsToDelete]; + const idsToDelete = allToDelete.map(v => v.id); - // 2. Delete from Storage for ALL items - for (const item of allToDelete) { - if (item.image_url?.includes('supabase.co/storage/')) { - const urlParts = item.image_url.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const bucketPath = `${item.user_id}/${fileName}`; + // 2. Batch delete via API (handles storage + db) + await deletePictures(idsToDelete); - await supabase.storage - .from('pictures') - .remove([bucketPath]); - } - } - - // 3. Delete from Database (bulk) - const { error: dbError } = await supabase - .from('pictures') - .delete() - .in('id', allToDelete.map(v => v.id)); - - if (dbError) throw dbError; - - // 4. Update local state - const deletedIds = new Set(allToDelete.map(v => v.id)); + // 3. Update local state + const deletedIds = new Set(idsToDelete); setVersions(prev => prev.filter(v => !deletedIds.has(v.id))); const totalDeleted = allToDelete.length; diff --git a/packages/ui/src/components/VideoCard.tsx b/packages/ui/src/components/VideoCard.tsx index e4378379..ce7c2961 100644 --- a/packages/ui/src/components/VideoCard.tsx +++ b/packages/ui/src/components/VideoCard.tsx @@ -1,6 +1,6 @@ import { Heart, Download, Share2, MessageCircle, Edit3, Trash2, Layers, Loader2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { supabase } from "@/integrations/supabase/client"; +import { deletePictures } from "@/modules/posts/client-pictures"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import React, { useState, useEffect, useRef } from "react"; @@ -323,38 +323,8 @@ const VideoCard = ({ setIsDeleting(true); try { - // First get the video details for storage cleanup - const { data: video, error: fetchError } = await supabase - .from('pictures') - .select('image_url') - .eq('id', videoId) - .single(); - - if (fetchError) throw fetchError; - - // Delete from database (this will cascade delete likes and comments due to foreign keys) - const { error: deleteError } = await supabase - .from('pictures') - .delete() - .eq('id', videoId); - - if (deleteError) throw deleteError; - - // Try to delete from storage as well (videos use image_url field for HLS URL) - if (video?.image_url) { - const urlParts = video.image_url.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const userIdFromUrl = urlParts[urlParts.length - 2]; - - const { error: storageError } = await supabase.storage - .from('videos') - .remove([`${userIdFromUrl}/${fileName}`]); - - if (storageError) { - console.error('Error deleting from storage:', storageError); - // Don't show error to user as the main deletion succeeded - } - } + // Delete via API (handles DB + storage cleanup internally) + await deletePictures([videoId]); toast.success(translate('Video deleted successfully')); onDelete?.(); // Trigger refresh of the parent component diff --git a/packages/ui/src/components/admin/EditUserDialog.tsx b/packages/ui/src/components/admin/EditUserDialog.tsx index 07639ed1..66c0a5cb 100644 --- a/packages/ui/src/components/admin/EditUserDialog.tsx +++ b/packages/ui/src/components/admin/EditUserDialog.tsx @@ -3,7 +3,7 @@ import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { T, translate } from '@/i18n'; import { Tables } from '@/integrations/supabase/types'; -import { getUserSecrets, updateUserSecrets } from '@/components/ImageWizard/db'; +import { getUserApiKeys as getUserSecrets, updateUserSecrets } from '@/modules/user/client-user'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; diff --git a/packages/ui/src/components/feed/FeedCard.tsx b/packages/ui/src/components/feed/FeedCard.tsx index 63c3873e..53b13fab 100644 --- a/packages/ui/src/components/feed/FeedCard.tsx +++ b/packages/ui/src/components/feed/FeedCard.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; import * as db from '@/lib/db'; import { useNavigate } from "react-router-dom"; import { normalizeMediaType } from "@/lib/mediaRegistry"; +import { toggleLike } from '@/modules/posts/client-pictures'; interface FeedCardProps { post: FeedPost; @@ -20,7 +21,6 @@ export const FeedCard: React.FC = ({ post, currentUserId, onLike, - onComment, onNavigate }) => { const navigate = useNavigate(); @@ -32,13 +32,6 @@ export const FeedCard: React.FC = ({ const [lastTap, setLastTap] = useState(0); const [showHeartAnimation, setShowHeartAnimation] = useState(false); - // Initial check removal: We now rely on server-provided `is_liked` status. - // React.useEffect(() => { - // if (currentUserId && post.cover?.id) { - // db.checkLikeStatus(currentUserId, post.cover.id).then(setIsLiked); - // } - // }, [currentUserId, post.cover?.id]); - const handleLike = async () => { if (!currentUserId || !post.cover?.id) return; @@ -48,7 +41,7 @@ export const FeedCard: React.FC = ({ setLikeCount(prev => newStatus ? prev + 1 : prev - 1); try { - await db.toggleLike(currentUserId, post.cover.id, isLiked); + await toggleLike(currentUserId, post.cover.id, isLiked); onLike?.(); } catch (e) { // Revert diff --git a/packages/ui/src/components/filters/FilterPanel.tsx b/packages/ui/src/components/filters/FilterPanel.tsx index 0824c22e..349871db 100644 --- a/packages/ui/src/components/filters/FilterPanel.tsx +++ b/packages/ui/src/components/filters/FilterPanel.tsx @@ -12,7 +12,6 @@ import { FilterSelector } from './FilterSelector'; import { ProviderManagement } from './ProviderManagement'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; @@ -33,9 +32,9 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { T, translate } from '@/i18n'; -import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; -import * as db from '@/pages/Post/db'; + +import { getUserSettings, updateUserSettings } from '@/modules/user/client-user'; interface FilterPanelProps { content: string; @@ -77,7 +76,7 @@ export const FilterPanel: React.FC = ({ } try { - const settings = await db.getUserSettings(user.id); + const settings = await getUserSettings(user.id); const filterSettings = settings?.filterPanel; if (filterSettings) { @@ -106,7 +105,7 @@ export const FilterPanel: React.FC = ({ // First, get current settings to merge - reusing the cached getter is safe here // or we could use updateUserSettings directly if we want to merge properly // For now, let's just use what was there before - const currentSettings = await db.getUserSettings(user.id); + const currentSettings = await getUserSettings(user.id); // Merge with new Filter Panel settings const updatedSettings = { @@ -119,7 +118,7 @@ export const FilterPanel: React.FC = ({ } }; - await db.updateUserSettings(user.id, updatedSettings); + await updateUserSettings(user.id, updatedSettings); console.log('FilterPanel settings saved:', updatedSettings.filterPanel); } catch (error) { console.error('Error saving filter settings:', error); diff --git a/packages/ui/src/components/filters/ProviderManagement.tsx b/packages/ui/src/components/filters/ProviderManagement.tsx index 45835d46..a25b95b2 100644 --- a/packages/ui/src/components/filters/ProviderManagement.tsx +++ b/packages/ui/src/components/filters/ProviderManagement.tsx @@ -12,7 +12,7 @@ import { useAuth } from '@/hooks/useAuth'; import { DEFAULT_PROVIDERS, fetchProviderModelInfo } from '@/llm/filters/providers'; import { groupModelsByCompany } from '@/llm/filters/providers/openrouter'; import { groupOpenAIModelsByType } from '@/llm/filters/providers/openai'; -import { getUserSecrets, updateUserSecrets } from '@/components/ImageWizard/db'; +import { getUserApiKeys as getUserSecrets, updateUserSecrets } from '@/modules/user/client-user'; import { cn } from '@/lib/utils'; import { Dialog, diff --git a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx index 141a0438..e8fa7ee8 100644 --- a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx @@ -6,7 +6,7 @@ import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@ import { AIPromptPopup } from './AIPromptPopup'; import { generateText } from '@/lib/openai'; import { toast } from 'sonner'; -import { getUserSecrets } from '@/components/ImageWizard/db'; +import { getUserApiKeys as getUserSecrets } from '@/modules/user/client-user'; import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; import { formatTextGenPrompt } from '@/constants'; diff --git a/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx index cc71d100..a8364eea 100644 --- a/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx @@ -4,7 +4,6 @@ import { $createParagraphNode, $getSelection, $isRangeSelection, COMMAND_PRIORIT import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor'; import { AIImagePromptPopup } from './AIImagePromptPopup'; import { createImage, editImage } from '@/lib/image-router'; -import { uploadFileToStorage } from '@/lib/db'; import { useAuth } from '@/hooks/useAuth'; import { toast } from 'sonner'; @@ -179,4 +178,5 @@ export const aiImageGenerationPlugin = (): RealmPlugin => { }; // Helper to access root node (Lexical hack if import missing) -import { $getRoot } from 'lexical'; +import { $getRoot } from 'lexical'; import { uploadFileToStorage } from '@/modules/posts/client-pictures'; + diff --git a/packages/ui/src/components/widgets/GalleryWidget.tsx b/packages/ui/src/components/widgets/GalleryWidget.tsx index 7e0795ab..2d034e90 100644 --- a/packages/ui/src/components/widgets/GalleryWidget.tsx +++ b/packages/ui/src/components/widgets/GalleryWidget.tsx @@ -8,11 +8,9 @@ import { Button } from '@/components/ui/button'; import { useAuth } from '@/hooks/useAuth'; import { PostMediaItem } from '@/pages/Post/types'; import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaRegistry'; -import { supabase } from '@/integrations/supabase/client'; import { uploadImage } from '@/lib/uploadUtils'; import { toast } from 'sonner'; - -const { fetchMediaItemsByIds } = await import('@/lib/db'); +import { fetchMediaItemsByIds, createPicture } from '@/modules/posts/client-pictures'; interface GalleryWidgetProps { pictureIds?: string[]; @@ -82,7 +80,6 @@ const GalleryWidget: React.FC = ({ setLoading(true); try { - const items = await fetchMediaItemsByIds(pictureIds, { maintainOrder: true }); // Transform to PostMediaItem format @@ -93,7 +90,7 @@ const GalleryWidget: React.FC = ({ visible: true, is_selected: false, comments: [{ count: 0 }] - })) as PostMediaItem[]; + })) as any[]; setMediaItems(postMediaItems); @@ -197,7 +194,6 @@ const GalleryWidget: React.FC = ({ const processDroppedImages = async (files: File[]) => { setIsUploading(true); try { - const { data: { user } } = await supabase.auth.getUser(); if (!user) { toast.error(translate('You must be logged in to upload images')); return; @@ -211,21 +207,15 @@ const GalleryWidget: React.FC = ({ // Upload const { publicUrl, meta } = await uploadImage(file, user.id); - // Create Record - const { data: pictureData, error: insertError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - title: file.name.split('.')[0] || 'Uploaded Image', - description: null, - image_url: publicUrl, - type: 'supabase-image', - meta: meta || {}, - }) - .select() - .single(); - - if (insertError) throw insertError; + // Create Record via API + const pictureData = await createPicture({ + user_id: user.id, + title: file.name.split('.')[0] || 'Uploaded Image', + description: null, + image_url: publicUrl, + type: 'supabase-image', + meta: meta || {}, + } as any); if (pictureData) { newPictureIds.push(pictureData.id); diff --git a/packages/ui/src/components/widgets/ImagePickerDialog.tsx b/packages/ui/src/components/widgets/ImagePickerDialog.tsx index 4c759491..406302b9 100644 --- a/packages/ui/src/components/widgets/ImagePickerDialog.tsx +++ b/packages/ui/src/components/widgets/ImagePickerDialog.tsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { supabase } from '@/integrations/supabase/client'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { supabase } from '@/integrations/supabase/client'; // Still needed for collections (no API yet) +import { fetchPictures as fetchPicturesAPI } from '@/modules/posts/client-pictures'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -59,28 +60,26 @@ export const ImagePickerDialog: React.FC = ({ const [selectedCollections, setSelectedCollections] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [selectedId, setSelectedId] = useState(currentValue || null); + const prevIsOpen = useRef(false); // Initial data fetch - only runs when dialog opens useEffect(() => { if (isOpen) { - fetchPictures(); - fetchTags(); + fetchPictures(); // Also extracts tags fetchCollections(); } }, [isOpen]); - // Sync props to local state - runs when props change + // Sync props to local state - only when dialog first opens (not on every render) useEffect(() => { - if (isOpen) { + const justOpened = isOpen && !prevIsOpen.current; + prevIsOpen.current = isOpen; + + if (justOpened) { if (multiple) { - // Only update if actually different to avoid cycles if parent passes new array ref - if (JSON.stringify(selectedIds) !== JSON.stringify(currentValues)) { - setSelectedIds(currentValues || []); - } + setSelectedIds(currentValues || []); } else { - if (selectedId !== currentValue) { - setSelectedId(currentValue || null); - } + setSelectedId(currentValue || null); } } }, [isOpen, currentValue, currentValues, multiple]); @@ -88,15 +87,18 @@ export const ImagePickerDialog: React.FC = ({ const fetchPictures = async () => { setLoading(true); try { - const { data, error } = await supabase - .from('pictures') - .select('id, title, image_url, thumbnail_url, type, user_id, tags, meta') - .eq('is_selected', true) - .order('created_at', { ascending: false }) - .limit(100); + // Fetch via API — returns all pictures, we filter client-side + const data = await fetchPicturesAPI({ limit: 100 }); + // Filter to only selected pictures + const selected = (data || []).filter((p: any) => p.is_selected); + setPictures(selected); - if (error) throw error; - setPictures(data || []); + // Extract unique tags from the same data + const tagsSet = new Set(); + selected.forEach((pic: any) => { + pic.tags?.forEach((tag: string) => tagsSet.add(tag)); + }); + setAllTags(Array.from(tagsSet).sort()); } catch (error) { console.error('Error fetching pictures:', error); } finally { @@ -104,27 +106,6 @@ export const ImagePickerDialog: React.FC = ({ } }; - const fetchTags = async () => { - try { - const { data, error } = await supabase - .from('pictures') - .select('tags') - .eq('is_selected', true); - - if (error) throw error; - - // Extract unique tags - const tagsSet = new Set(); - data?.forEach(pic => { - pic.tags?.forEach(tag => tagsSet.add(tag)); - }); - - setAllTags(Array.from(tagsSet).sort()); - } catch (error) { - console.error('Error fetching tags:', error); - } - }; - const fetchCollections = async () => { if (!user) return; @@ -217,6 +198,10 @@ export const ImagePickerDialog: React.FC = ({ } }; + console.log('selectedIds', selectedIds); + console.log('selectedId', selectedId); + + return ( diff --git a/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx b/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx index 101ca0d7..169d3294 100644 --- a/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx +++ b/packages/ui/src/components/widgets/MarkdownTextWidget-Edit.tsx @@ -24,9 +24,10 @@ import { createMarkdownToolPreset } from '@/lib/markdownImageTools'; import { toast } from 'sonner'; import { Card, CardContent } from '@/components/ui/card'; import AITextGenerator from '@/components/AITextGenerator'; -import { getUserSecrets } from '@/components/ImageWizard/db'; +import { getUserApiKeys as getUserSecrets } from '@/modules/user/client-user'; import * as db from '@/lib/db'; import CollapsibleSection from '@/components/CollapsibleSection'; +import { getProviderConfig, getUserSettings, updateUserSettings } from '@/modules/user/client-user'; interface MarkdownTextWidgetEditProps { content?: string; @@ -87,7 +88,7 @@ const MarkdownTextWidgetEdit: React.FC = ({ } try { - const settings = await db.getUserSettings(user.id); + const settings = await getUserSettings(user.id); const aiTextSettings = settings?.aiTextGenerator; if (aiTextSettings) { @@ -118,7 +119,7 @@ const MarkdownTextWidgetEdit: React.FC = ({ const saveSettings = async () => { try { - const currentSettings = await db.getUserSettings(user.id); + const currentSettings = await getUserSettings(user.id); const updatedSettings = { ...currentSettings, aiTextGenerator: { @@ -130,7 +131,7 @@ const MarkdownTextWidgetEdit: React.FC = ({ applicationMode, } }; - await db.updateUserSettings(user.id, updatedSettings); + await updateUserSettings(user.id, updatedSettings); } catch (error) { console.error('Error saving AITextGenerator settings:', error); } @@ -236,7 +237,7 @@ const MarkdownTextWidgetEdit: React.FC = ({ } } try { - const userProvider = await db.getProviderConfig(user.id, provider); + const userProvider = await getProviderConfig(user.id, provider); if (!userProvider) { if (provider !== 'openai') console.warn(`No provider configuration found for ${provider}`); return null; diff --git a/packages/ui/src/components/widgets/PhotoCardWidget.tsx b/packages/ui/src/components/widgets/PhotoCardWidget.tsx index 9feab5ff..ef89fd68 100644 --- a/packages/ui/src/components/widgets/PhotoCardWidget.tsx +++ b/packages/ui/src/components/widgets/PhotoCardWidget.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { supabase } from '@/integrations/supabase/client'; +import { fetchPictureById, createPicture } from '@/modules/posts/client-pictures'; import PhotoCard from '@/components/PhotoCard'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; import { T, translate } from '@/i18n'; import { ImageIcon, Plus, Upload, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { useAuth } from '@/hooks/useAuth'; import { uploadImage } from '@/lib/uploadUtils'; import { toast } from 'sonner'; @@ -45,6 +46,7 @@ const PhotoCardWidget: React.FC = ({ imageFit = 'cover', onPropsChange }) => { + const { user } = useAuth(); const [pictureId, setPictureId] = useState(propPictureId); const [picture, setPicture] = useState(null); const [userProfile, setUserProfile] = useState(null); @@ -107,33 +109,17 @@ const PhotoCardWidget: React.FC = ({ setLoading(true); try { - // Fetch picture - const { data: pictureData, error: pictureError } = await supabase - .from('pictures') - .select('*') - .eq('id', pictureId) - .single(); - - if (pictureError) throw pictureError; + // Fetch enriched picture via API (includes profile + comments_count) + const pictureData = await fetchPictureById(pictureId); setPicture(pictureData); - console.log('Fetching profile for user:', pictureData.user_id); - // Fetch user profile - const { data: profileData } = await supabase - .from('profiles') - .select('display_name, username, avatar_url') - .eq('user_id', pictureData.user_id) - .single(); + // Profile is included in the enriched response + if (pictureData?.profile) { + setUserProfile(pictureData.profile); + } - setUserProfile(profileData); - - // Fetch comments count - const { count } = await supabase - .from('comments') - .select('*', { count: 'exact', head: true }) - .eq('picture_id', pictureId); - - setCommentsCount(count || 0); + // Comments count is included in the enriched response + setCommentsCount(pictureData?.comments_count || 0); } catch (error) { console.error('Error fetching picture data:', error); } finally { @@ -220,7 +206,6 @@ const PhotoCardWidget: React.FC = ({ const processDroppedImage = async (file: File) => { setIsUploading(true); try { - const { data: { user } } = await supabase.auth.getUser(); if (!user) { toast.error(translate('You must be logged in to upload images')); return; @@ -229,25 +214,15 @@ const PhotoCardWidget: React.FC = ({ // 1. Upload to storage const { publicUrl, meta } = await uploadImage(file, user.id); - // 2. Create picture record - const { data: pictureData, error: insertError } = await supabase - .from('pictures') - .insert({ - user_id: user.id, - title: file.name.split('.')[0] || 'Uploaded Image', - description: null, - image_url: publicUrl, - type: 'supabase-image', - meta: meta || {}, - // We don't link to a post here yet, it's just a picture in their library - // unless we want to auto-create a post? - // For a widget, we usually just point to the picture. - // The widget displays a "Picture", so we need a picture record. - }) - .select() - .single(); - - if (insertError) throw insertError; + // 2. Create picture record via API + const pictureData = await createPicture({ + user_id: user.id, + title: file.name.split('.')[0] || 'Uploaded Image', + description: null, + image_url: publicUrl, + type: 'supabase-image', + meta: meta || {}, + } as any); if (pictureData) { // 3. Update widget props diff --git a/packages/ui/src/hooks/useAuth.tsx b/packages/ui/src/hooks/useAuth.tsx index 35315a5a..cf44c622 100644 --- a/packages/ui/src/hooks/useAuth.tsx +++ b/packages/ui/src/hooks/useAuth.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useState, useRef } from 'react'; import { User, Session } from '@supabase/supabase-js'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; -import * as db from '@/pages/Post/db'; +import { fetchUserRoles } from '@/modules/user/client-user'; interface AuthContextType { user: User | null; @@ -68,7 +68,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { } // Fetch fresh roles independently - db.fetchUserRoles(session.user.id).then(roles => { + fetchUserRoles(session.user.id).then(roles => { if (!mounted) return; // Update cache diff --git a/packages/ui/src/hooks/useFeedData.ts b/packages/ui/src/hooks/useFeedData.ts index 492cefa7..2616885f 100644 --- a/packages/ui/src/hooks/useFeedData.ts +++ b/packages/ui/src/hooks/useFeedData.ts @@ -1,9 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { FeedPost } from '@/lib/db'; -import * as db from '@/lib/db'; import { FEED_API_ENDPOINT, FEED_PAGE_SIZE } from '@/constants'; import { useProfiles } from '@/contexts/ProfilesContext'; import { useFeedCache } from '@/contexts/FeedCacheContext'; +import { augmentFeedPosts, FeedPost } from '@/modules/posts/client-posts'; const { supabase } = await import('@/integrations/supabase/client'); @@ -162,7 +161,7 @@ export const useFeedData = ({ } // Augment posts (ensure cover, author profiles etc are set) - const augmentedPosts = db.augmentFeedPosts(fetchedPosts); + const augmentedPosts = augmentFeedPosts(fetchedPosts); // Update cover based on sort mode const postsWithUpdatedCovers = updatePostCovers(augmentedPosts, sortBy); diff --git a/packages/ui/src/hooks/usePromptHistory.ts b/packages/ui/src/hooks/usePromptHistory.ts index 8536c18d..1898ef56 100644 --- a/packages/ui/src/hooks/usePromptHistory.ts +++ b/packages/ui/src/hooks/usePromptHistory.ts @@ -1,10 +1,9 @@ import { useState, useEffect, useCallback } from 'react'; import { useAuth } from './useAuth'; -import { supabase } from '@/integrations/supabase/client'; import { useLog } from '@/contexts/LogContext'; import { toast } from 'sonner'; import { translate } from '@/i18n'; -import { getUserSettings, updateUserSettings } from '@/lib/db'; +import { getUserSettings, updateUserSettings } from '@/modules/user/client-user'; const MAX_HISTORY_LENGTH = 50; diff --git a/packages/ui/src/image-api.ts b/packages/ui/src/image-api.ts index 0680f546..a282e0c8 100644 --- a/packages/ui/src/image-api.ts +++ b/packages/ui/src/image-api.ts @@ -1,5 +1,6 @@ import { GoogleGenerativeAI, Part, GenerationConfig } from "@google/generative-ai"; import { supabase } from "@/integrations/supabase/client"; +import { getUserGoogleApiKey } from "./modules/user/client-user"; // Simple logger for user feedback (safety messages) const logger = { @@ -35,8 +36,6 @@ interface ImageResult { text?: string; } -import { getUserGoogleApiKey } from '@/lib/db'; - // Get user's Google API key from user_secrets export const getGoogleApiKey = async (): Promise => { try { diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 0524c25d..ba37fafb 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -1,27 +1,6 @@ -import { supabase as defaultSupabase, supabase } from "@/integrations/supabase/client"; +import { supabase as defaultSupabase } from "@/integrations/supabase/client"; import { z } from "zod"; -import { UserProfile, PostMediaItem } from "@/pages/Post/types"; -import { MediaType, MediaItem } from "@/types"; import { SupabaseClient } from "@supabase/supabase-js"; -import { Database } from "@/integrations/supabase/types"; -import { FetchMediaOptions } from "@/utils/mediaUtils"; - -export interface FeedPost { - id: string; // Post ID - title: string; - description: string | null; - created_at: string; - user_id: string; - pictures: MediaItem[]; // All visible pictures - cover: MediaItem; // The selected cover picture - likes_count: number; - comments_count: number; - type: MediaType; - author?: UserProfile; - settings?: any; - is_liked?: boolean; - category_paths?: any[][]; // Array of category paths (each path is root -> leaf) -} // Deprecated: Caching now handled by React Query // Keeping for backward compatibility @@ -47,282 +26,7 @@ export const invalidateServerCache = async (types: string[]) => { console.debug('invalidateServerCache: Skipped manual invalidation for', types); }; -export const fetchPostDetailsAPI = async (id: string, options: { sizes?: string, formats?: string } = {}) => { - const params = new URLSearchParams(); - if (options.sizes) params.set('sizes', options.sizes); - if (options.formats) params.set('formats', options.formats); - const qs = params.toString(); - const url = `/api/posts/${id}${qs ? `?${qs}` : ''}`; - - // We rely on the browser/hook to handle auth headers if global fetch is intercepted, - // OR we explicitly get session? - // Usually standard `fetch` in our app might not send auth if using implicit flows or we need to pass headers. - // In `useFeedData`, we manually added headers. - // Let's assume we need to handle auth here or use a helper that does. - // To keep it simple for now, we'll import `supabase` and get session. - - const { supabase } = await import('@/integrations/supabase/client'); - const { data: { session } } = await supabase.auth.getSession(); - - const headers: Record = {}; - if (session?.access_token) { - headers['Authorization'] = `Bearer ${session.access_token}`; - } - - const res = await fetch(url, { headers }); - if (!res.ok) { - if (res.status === 404) return null; - throw new Error(`Failed to fetch post: ${res.statusText}`); - } - - return res.json(); -}; - -export const fetchPostById = async (id: string, client?: SupabaseClient) => { - // Use API-mediated fetching instead of direct Supabase calls - // This returns enriched FeedPost data including category_paths, author info, etc. - return fetchWithDeduplication(`post-${id}`, async () => { - const data = await fetchPostDetailsAPI(id); - if (!data) return null; - return data; - }, 1); -}; - -export const fetchPictureById = async (id: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`picture-${id}`, async () => { - const { data, error } = await supabase - .from('pictures') - .select(` - *, - post:posts ( - * - ) - `) - .eq('id', id) - .maybeSingle(); - - if (error) throw error; - return data; - }); -}; - -/** - * Fetch multiple media items by IDs using the server API endpoint - * This leverages the server's caching layer for optimal performance - */ -export const fetchMediaItemsByIds = async ( - ids: string[], - options?: { - maintainOrder?: boolean; - client?: SupabaseClient; - } -): Promise => { - if (!ids || ids.length === 0) return []; - - // Build query parameters - const params = new URLSearchParams({ - ids: ids.join(','), - }); - - if (options?.maintainOrder) { - params.append('maintainOrder', 'true'); - } - - // Call server API endpoint - const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://localhost:3333'; - const response = await fetch(`${serverUrl}/api/media-items?${params.toString()}`); - - if (!response.ok) { - throw new Error(`Failed to fetch media items: ${response.statusText}`); - } - - const data = await response.json(); - - // The server returns raw Supabase data, so we need to adapt it - const { adaptSupabasePicturesToMediaItems } = await import('@/pages/Post/adapters'); - return adaptSupabasePicturesToMediaItems(data); -}; - -/** - * Filters out pictures that belong to private collections the user doesn't have access to - * (Placeholder implementation - currently returns all pictures) - */ -async function filterPrivateCollectionPictures(pictures: any[]): Promise { - // TODO: Implement actual privacy filtering logic based on collections - return pictures; -} - -/** - * Fetches and merges pictures and videos from the database - * Returns a unified array of media items sorted by created_at - */ -export async function fetchMediaItems(options: FetchMediaOptions = {}): Promise { - const { organizationId, includePrivate = false, limit, userId, tag } = options; - - try { - // Fetch pictures - let picturesQuery = supabase - .from('pictures') - .select('*') - .eq('is_selected', true) - .eq('visible', true); - - // Apply filters - if (organizationId !== undefined) { - if (organizationId === null) { - picturesQuery = picturesQuery.is('organization_id', null); - } else { - picturesQuery = picturesQuery.eq('organization_id', organizationId); - } - } - - if (userId) { - picturesQuery = picturesQuery.eq('user_id', userId); - } - - if (tag) { - picturesQuery = picturesQuery.contains('tags', [tag]); - } - - const { data: picturesData, error: picturesError } = await picturesQuery - .order('created_at', { ascending: false }); - - if (picturesError) throw picturesError; - - // Filter private collections if needed - const publicPictures = includePrivate - ? (picturesData || []) - : await filterPrivateCollectionPictures(picturesData || []); - - // Get comment counts for pictures - const picturesWithComments = await Promise.all( - publicPictures.map(async (picture) => { - const { count } = await supabase - .from('comments') - .select('*', { count: 'exact', head: true }) - .eq('picture_id', picture.id); - - return { ...picture, comments_count: count || 0 }; - }) - ); - - // Convert to unified MediaItem format - // Videos have type='mux-video' or type='video', everything else is a picture - // Cast to any to bypass strict type checking for legacy MediaItem shape mismatch - const allMedia: MediaItem[] = (picturesWithComments.map(p => { - const isLegacyVideo = p.type === 'video-intern'; - let url = p.image_url; // Default for pictures - if (isLegacyVideo) { - url = p.image_url; - } - - return { - id: p.id, - type: isLegacyVideo ? 'video' : 'picture', - title: p.title, - description: p.description, - url: url, // Use the constructed URL - thumbnail_url: p.thumbnail_url, - likes_count: p.likes_count || 0, - created_at: p.created_at, - user_id: p.user_id, - comments_count: p.comments_count, - meta: p.meta, - }; - }) as any[]).sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - - // Apply limit if specified - return limit ? allMedia.slice(0, limit) : allMedia; - } catch (error) { - console.error('Error fetching media items:', error); - throw error; - } -} - -/** - * Fetches user likes for all media (both pictures and videos) - * Note: Videos are also stored in the pictures table with type='mux-video' - */ -export async function fetchUserMediaLikes(userId: string): Promise<{ - pictureLikes: Set; - videoLikes: Set; -}> { - try { - // Fetch all likes (both pictures and videos are in the same table) - const { data: likesData, error: likesError } = await supabase - .from('likes') - .select('picture_id') - .eq('user_id', userId); - - if (likesError) throw likesError; - - // Since videos are also in the pictures table, all likes are in picture_id - const allLikedIds = new Set(likesData?.map(like => like.picture_id) || []); - - return { - pictureLikes: allLikedIds, - videoLikes: allLikedIds, // Same set since videos are also in pictures table - }; - } catch (error) { - console.error('Error fetching user likes:', error); - throw error; - } -} - - - -export const fetchFullPost = async (postId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`full-post-${postId}`, async () => { - const { data, error } = await supabase - .from('posts') - .select(`*, pictures (*)`) - .eq('id', postId) - .single(); - - if (error) throw error; - return data; - }); -}; - -export const fetchAuthorProfile = async (userId: string, client?: SupabaseClient): Promise => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`profile-${userId}`, async () => { - console.log('Fetching profile for user:', userId); - const { data, error } = await supabase - .from('profiles') - .select('user_id, avatar_url, display_name, username') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') throw error; - return data as UserProfile; - }); -}; - -export const fetchVersions = async (mediaItem: PostMediaItem, userId?: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const key = `versions-${mediaItem.id}-${userId || 'anon'}`; - return fetchWithDeduplication(key, async () => { - let query = supabase - .from('pictures') - .select('*') - .or(`parent_id.eq.${mediaItem.parent_id || mediaItem.id},id.eq.${mediaItem.parent_id || mediaItem.id}`) - .or('type.is.null,type.eq.supabase-image'); - - if (!userId || userId !== mediaItem.user_id) { - query = query.eq('visible', true); - } - - const { data, error } = await query.order('created_at', { ascending: true }); - if (error) throw error; - return data; - }); -}; export const checkLikeStatus = async (userId: string, pictureId: string, client?: SupabaseClient) => { const supabase = client || defaultSupabase; @@ -339,437 +43,6 @@ export const checkLikeStatus = async (userId: string, pictureId: string, client? }); }; -export const toggleLike = async (userId: string, pictureId: string, isLiked: boolean, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - - if (isLiked) { - const { error } = await supabase - .from('likes') - .delete() - .eq('user_id', userId) - .eq('picture_id', pictureId); - if (error) throw error; - return false; - } else { - const { error } = await supabase - .from('likes') - .insert([{ user_id: userId, picture_id: pictureId }]); - if (error) throw error; - return true; - } -}; - -export const uploadFileToStorage = async (userId: string, file: File | Blob, fileName?: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const name = fileName || `${userId}/${Date.now()}-${Math.random().toString(36).substring(7)}`; - const { error } = await supabase.storage.from('pictures').upload(name, file); - if (error) throw error; - - const { data: { publicUrl } } = supabase.storage.from('pictures').getPublicUrl(name); - return publicUrl; -}; - -export const createPicture = async (picture: Partial, client?: SupabaseClient) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch('/api/pictures', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(picture) - }); - - if (!response.ok) { - throw new Error(`Failed to create picture: ${response.statusText}`); - } - - return await response.json(); -}; - -export const updatePicture = async (id: string, updates: Partial, client?: SupabaseClient) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch(`/api/pictures/${id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(updates) - }); - - if (!response.ok) { - throw new Error(`Failed to update picture: ${response.statusText}`); - } - - return await response.json(); -}; - -export const deletePicture = async (id: string, client?: SupabaseClient) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch(`/api/pictures/${id}`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Failed to delete picture: ${response.statusText}`); - } - - return await response.json(); -}; - -export const deletePost = async (id: string, client?: SupabaseClient) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch(`/api/posts/${id}`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (!response.ok) { - throw new Error(`Failed to delete post: ${response.statusText}`); - } - - return await response.json(); -}; - -export const createPost = async (postData: { title: string, description?: string, settings?: any, meta?: any }) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch(`/api/posts`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(postData) - }); - - if (!response.ok) { - throw new Error(`Failed to create post: ${response.statusText}`); - } - - return await response.json(); -}; - -export const updatePostDetails = async (postId: string, updates: { title?: string, description?: string, settings?: any, meta?: any }, client?: SupabaseClient) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch(`/api/posts/${postId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(updates) - }); - - if (!response.ok) { - throw new Error(`Failed to update post details: ${response.statusText}`); - } - - return await response.json(); -}; - - -export const unlinkPictures = async (ids: string[], client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const { error } = await supabase - .from('pictures') - .update({ post_id: null, position: -1 } as any) - .in('id', ids); - if (error) throw error; -}; - -export const upsertPictures = async (pictures: Partial[], client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const { error } = await supabase - .from('pictures') - .upsert(pictures as any, { onConflict: 'id' }); - if (error) throw error; -}; - -export const getUserSettings = async (userId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`settings-${userId}`, async () => { - const { data, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - if (error) throw error; - return (data?.settings as any) || {}; - }, 100000); -}; - -export const updateUserSettings = async (userId: string, settings: any, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - await supabase - .from('profiles') - .update({ settings }) - .eq('user_id', userId); - - // Cache invalidation handled by React Query or not needed for now -}; - -export const getUserOpenAIKey = async (userId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`openai-${userId}`, async () => { - const { data, error } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .maybeSingle(); - - if (error) throw error; - const settings = data?.settings as any; - return settings?.api_keys?.openai_api_key; - }); -} - -export const getUserGoogleApiKey = async (userId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`google-${userId}`, async () => { - const { data, error } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .maybeSingle(); - - if (error) throw error; - const settings = data?.settings as any; - return settings?.api_keys?.google_api_key; - }); -} - -export const getUserSecrets = async (userId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`user-secrets-${userId}`, async () => { - console.log('Fetching user secrets for user:', userId); - const { data, error } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .maybeSingle(); - - if (error) throw error; - - // Return whole settings object or specific part? - // Instructions involve variables in settings.variables - const settings = data?.settings as any; - return settings?.variables || {}; - }); -}; - -export const getProviderConfig = async (userId: string, provider: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`provider-${userId}-${provider}`, async () => { - console.log('Fetching provider config for user:', userId, 'provider:', provider); - const { data, error } = await supabase - .from('provider_configs') - .select('settings') - .eq('user_id', userId) - .eq('name', provider) - .eq('is_active', true) - .single(); - - if (error) { - // It's common to not have configs for all providers, so we might want to suppress some errors or handle them gracefully - // However, checking error code might be robust. For now let's just throw if it's not a "no rows" error, - // or just return null if not found. - // The original code used .single() which errors if 0 rows. - // Let's use maybeSingle() to be safe? The original code caught the error and returned null. - // But the original query strictly used .single(). - // Let's stick to .single() but catch it here if we want to mimic exact behavior, OR use maybeSingle and return null. - // The calling code expects null if not found. - if (error.code === 'PGRST116') return null; // No rows found - throw error; - } - return data as { settings: any }; - }); -} - -export const fetchUserPage = async (userId: string, slug: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const key = `user-page-${userId}-${slug}`; - return fetchWithDeduplication(key, async () => { - console.log('Fetching user page for user:', userId, 'slug:', slug); - const { data: sessionData } = await supabase.auth.getSession(); - const token = sessionData.session?.access_token; - - const headers: HeadersInit = {}; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await fetch(`/api/user-page/${userId}/${slug}`, { headers }); - - if (!res.ok) { - if (res.status === 404) return null; - throw new Error(`Failed to fetch user page: ${res.statusText}`); - } - - return await res.json(); - }, 10); -}; - - -export const addCollectionPictures = async (inserts: { collection_id: string, picture_id: string }[], client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const { error } = await supabase - .from('collection_pictures') - .insert(inserts); - if (error) throw error; -}; - -export const updateStorageFile = async (path: string, blob: Blob, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - const { error } = await supabase.storage.from('pictures').update(path, blob, { - cacheControl: '0', - upsert: true - }); - if (error) throw error; -}; - -export const fetchSelectedVersions = async (rootIds: string[], client?: SupabaseClient) => { - console.log('fetchSelectedVersions', rootIds); - const supabase = client || defaultSupabase; - if (rootIds.length === 0) return []; - - // Sort ids to ensure consistent cache key - const sortedIds = [...rootIds].sort(); - const key = `selected-versions-${sortedIds.join(',')}`; - - return fetchWithDeduplication(key, async () => { - // safe query format - const idsString = `(${rootIds.join(',')})`; - const { data, error } = await supabase - .from('pictures') - .select('*') - .eq('is_selected', true) - .or(`parent_id.in.${idsString},id.in.${idsString}`); - - if (error) throw error; - return data; - }); -}; - - -export const fetchUserRoles = async (userId: string, client?: SupabaseClient) => { - const supabase = client || defaultSupabase; - return fetchWithDeduplication(`roles-${userId}`, async () => { - const { data, error } = await supabase - .from('user_roles') - .select('role') - .eq('user_id', userId); - - if (error) { - console.error('Error fetching user roles:', error); - return []; - } - return data.map(r => r.role); - }); -}; - -// Map FeedPost[] back to MediaItemType[] for backward compatibility w/ PhotoGrid -export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | 'top' = 'latest'): any[] => { - return posts.map(post => { - let cover = post.cover; - - // Select cover based on sort mode - if (post.pictures && post.pictures.length > 0) { - const validPics = post.pictures.filter((p: any) => p.visible !== false); - - if (validPics.length > 0) { - if (sortBy === 'latest') { - // For "Latest" mode: Show the newest picture (by created_at) - cover = validPics.reduce((newest, current) => { - const newestDate = new Date(newest.created_at || post.created_at).getTime(); - const currentDate = new Date(current.created_at || post.created_at).getTime(); - return currentDate > newestDate ? current : newest; - }); - } else { - // For "Top" or default: Use first by position (existing behavior) - const sortedByPosition = [...validPics].sort((a, b) => (a.position || 0) - (b.position || 0)); - cover = sortedByPosition[0]; - } - } else { - cover = post.pictures[0]; // Fallback to any picture - } - } - - if (!cover) return null; - - const versionCount = post.pictures ? post.pictures.length : 1; - - return { - id: post.id, - picture_id: cover.id, - title: post.title, - description: post.description, - image_url: cover.image_url, - thumbnail_url: cover.thumbnail_url, - type: ((cover as any).type || (cover as any).mediaType) as MediaType, // Handle both legacy 'type' and new 'mediaType' - meta: cover.meta, - created_at: post.created_at, - user_id: post.user_id, - likes_count: post.likes_count, - comments: [{ count: post.comments_count }], - responsive: (cover as any).responsive, - job: (cover as any).job, - author: post.author, - versionCount - }; - }).filter(item => item !== null); -}; - -// Augment posts if they come from API/Hydration (missing cover/author) -export const augmentFeedPosts = (posts: any[]): FeedPost[] => { - return posts.map(p => { - // Check if we need to augment (heuristic: missing cover) - if (!p.cover) { - const pics = p.pictures || []; - const validPics = pics.filter((pic: any) => pic.visible !== false) - .sort((a: any, b: any) => (a.position || 0) - (b.position || 0)); - - return { - ...p, - cover: validPics[0] || pics[0], // fallback to first if none visible? - author: p.author || (p.author ? { - user_id: p.author.user_id, - username: p.author.username, - display_name: p.author.display_name, - avatar_url: p.author.avatar_url - } : undefined) - }; - } - return p; - }); -}; // --- Category Management --- export interface Category { id: string; @@ -859,27 +132,6 @@ export const deleteCategory = async (id: string) => { return await res.json(); }; -export const updatePostMeta = async (postId: string, meta: any, client?: SupabaseClient) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const response = await fetch(`/api/posts/${postId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ meta }) - }); - - if (!response.ok) { - throw new Error(`Failed to update post meta: ${response.statusText}`); - } - - return await response.json(); -}; export const fetchAnalytics = async (options: { limit?: number, startDate?: string, endDate?: string } = {}) => { const { data: sessionData } = await defaultSupabase.auth.getSession(); @@ -1046,114 +298,3 @@ export const deleteGlossary = async (id: string) => { invalidateCache('i18n-glossaries'); return true; }; -/** - * Update user secrets in user_secrets table (settings column) - */ -export const updateUserSecrets = async (userId: string, secrets: Record): Promise => { - try { - // Check if record exists - const { data: existing } = await defaultSupabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .maybeSingle(); - - if (existing) { - // Update existing - const currentSettings = (existing.settings as Record) || {}; - const currentApiKeys = (currentSettings.api_keys as Record) || {}; - - const newSettings = { - ...currentSettings, - api_keys: { ...currentApiKeys, ...secrets } - }; - - const { error } = await defaultSupabase - .from('user_secrets') - .update({ settings: newSettings }) - .eq('user_id', userId); - - if (error) throw error; - } else { - // Insert new - const { error } = await defaultSupabase - .from('user_secrets') - .insert({ - user_id: userId, - settings: { api_keys: secrets } - }); - - if (error) throw error; - } - } catch (error) { - console.error('Error updating user secrets:', error); - throw error; - } -}; - -/** - * Get user variables from user_secrets table (settings.variables) - */ -export const getUserVariables = async (userId: string): Promise | null> => { - console.log('getUserVariables Fetching user variables for user:', userId); - try { - const { data: secretData } = await defaultSupabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .single(); - - if (!secretData?.settings) return null; - - const settings = secretData.settings as Record; - return (settings.variables as Record) || {}; - } catch (error) { - console.error('Error fetching user variables:', error); - return null; - } -}; - -/** - * Update user variables in user_secrets table (settings.variables) - */ -export const updateUserVariables = async (userId: string, variables: Record): Promise => { - console.log('Updating user variables for user:', userId); - try { - // Check if record exists - const { data: existing } = await defaultSupabase - .from('user_secrets') - .select('settings') - .eq('user_id', userId) - .maybeSingle(); - - if (existing) { - // Update existing - const currentSettings = (existing.settings as Record) || {}; - - const newSettings = { - ...currentSettings, - variables: variables // Replace variables entirely with new state (since editor manages full set) - }; - - const { error } = await defaultSupabase - .from('user_secrets') - .update({ settings: newSettings }) - .eq('user_id', userId); - - if (error) throw error; - } else { - // Insert new - const { error } = await defaultSupabase - .from('user_secrets') - .insert({ - user_id: userId, - settings: { variables: variables } - }); - - if (error) throw error; - } - } catch (error) { - console.error('Error updating user variables:', error); - throw error; - } -}; diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index 0fc0d5c5..18cd2486 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -493,7 +493,7 @@ export function registerAllWidgets() { description: 'Browse files and directories on VFS mounts', icon: Monitor, defaultProps: { - mount: 'test', + mount: 'root', path: '/', glob: '*.*', mode: 'simple', diff --git a/packages/ui/src/modules/pages/FileBrowserWidget.tsx b/packages/ui/src/modules/pages/FileBrowserWidget.tsx index 08ccf139..96425c56 100644 --- a/packages/ui/src/modules/pages/FileBrowserWidget.tsx +++ b/packages/ui/src/modules/pages/FileBrowserWidget.tsx @@ -7,6 +7,7 @@ import { Archive, FileSpreadsheet, Presentation } from 'lucide-react'; import type { FileBrowserWidgetProps } from '@polymech/shared'; +import { useAuth } from '@/hooks/useAuth'; // ── Types ──────────────────────────────────────────────────────── @@ -116,9 +117,10 @@ function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] { // ── Thumbnail helper ───────────────────────────────────────────── -function ThumbPreview({ node, mount, height = 64 }: { node: INode; mount: string; height?: number }) { +function ThumbPreview({ node, mount, height = 64, tokenParam = '' }: { node: INode; mount: string; height?: number; tokenParam?: string }) { const cat = getMimeCategory(node); - const fileUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`; + const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`; + const fileUrl = tokenParam ? `${baseUrl}?${tokenParam}` : baseUrl; if (cat === 'image') { return {node.name}; } @@ -149,7 +151,7 @@ const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--b const FileBrowserWidget: React.FC = (props) => { const { - mount = 'test', + mount = 'root', path: initialPath = '/', glob = '*.*', mode = 'simple', @@ -158,6 +160,9 @@ const FileBrowserWidget: React.FC showToolbar = true, } = props; + const { session } = useAuth(); + const accessToken = session?.access_token; + const [currentPath, setCurrentPath] = useState(initialPath); const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(false); @@ -201,7 +206,9 @@ const FileBrowserWidget: React.FC ? `/api/vfs/ls/${encodeURIComponent(mount)}/${clean}` : `/api/vfs/ls/${encodeURIComponent(mount)}`; const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base; - const res = await fetch(url); + const headers: Record = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + const res = await fetch(url, { headers }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); @@ -213,11 +220,14 @@ const FileBrowserWidget: React.FC } finally { setLoading(false); } - }, [mount, glob]); + }, [mount, glob, accessToken]); useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]); useEffect(() => { setCurrentPath(initialPath); }, [initialPath]); + // Build a URL with optional auth token for /
- {/* Organizations Section */} - {organizations.length > 0 && ( -
-

Organizations

-
- {organizations.map((org) => ( - -
- - {org.name.charAt(0).toUpperCase()} - -
-
- - {org.name} - - - {org.role} - -
- - ))} -
-
- )} + {/* Pages Section */}
diff --git a/packages/ui/src/pages/VersionMap.tsx b/packages/ui/src/pages/VersionMap.tsx index 96bc139d..7b2ce097 100644 --- a/packages/ui/src/pages/VersionMap.tsx +++ b/packages/ui/src/pages/VersionMap.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { ReactFlow, Node, Edge, Background, Controls, MiniMap, useNodesState, useEdgesState, NodeProps, EdgeProps, getBezierPath, BaseEdge, EdgeLabelRenderer, Handle, Position } from "@xyflow/react"; -import { supabase } from "@/integrations/supabase/client"; +import { fetchPictureById, fetchUserPictures, deletePictures } from "@/modules/posts/client-pictures"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Wand2, Trash2, Edit3, Check } from "lucide-react"; import { toast } from "sonner"; @@ -243,10 +243,6 @@ const nodeTypes = { version: VersionNode, }; -const edgeTypes = { - pipe: PipeEdge, -}; - const VersionMap = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -275,27 +271,16 @@ const VersionMap = () => { try { // First get the current picture to determine the parent - const { data: currentPicture, error: currentError } = await supabase - .from('pictures') - .select('*') - .eq('id', id) - .single(); + const currentPicture = await fetchPictureById(id); - if (currentError) throw currentError; + if (!currentPicture) throw new Error('Picture not found'); if (ENABLE_GRAPH_DEBUG) console.log('🔧 [VersionMap] Current picture:', currentPicture); - // Fast approach: Get ALL pictures for this user, then filter in memory - // This is much faster than multiple recursive queries - const { data: allUserPictures, error } = await supabase - .from('pictures') - .select('*') - .eq('user_id', currentPicture.user_id) - .order('created_at', { ascending: true }); + // Fetch ALL pictures for this user via API, then filter in memory + const userPictures = await fetchUserPictures(currentPicture.user_id); - if (error) throw error; - - if (ENABLE_GRAPH_DEBUG) console.log('🔧 [VersionMap] Fetched all user pictures:', allUserPictures?.length); + if (ENABLE_GRAPH_DEBUG) console.log('🔧 [VersionMap] Fetched all user pictures:', userPictures?.length); // Find the complete version tree in memory const findVersionTree = (pictures: Picture[], startId: string): Picture[] => { @@ -331,11 +316,11 @@ const VersionMap = () => { return result; }; - const versionsData = findVersionTree(allUserPictures || [], currentPicture.id); + const versionsData = findVersionTree(userPictures || [], currentPicture.id); if (ENABLE_GRAPH_DEBUG) console.log('🔧 [VersionMap] Version tree found:', versionsData.length, 'versions'); setVersions(versionsData || []); - setAllUserPictures(allUserPictures || []); + setAllUserPictures(userPictures || []); createNodesAndEdges(versionsData || [], id); } catch (error) { console.error('🔧 [VersionMap] Error fetching versions:', error); @@ -377,27 +362,9 @@ const VersionMap = () => { try { // Delete all descendants first, then the parent const allToDelete = [versionToDelete, ...descendantsToDelete]; + const idsToDelete = allToDelete.map(item => item.id); - for (const item of allToDelete) { - // Delete from storage - if (item.image_url?.includes('supabase.co/storage/')) { - const urlParts = item.image_url.split('/'); - const fileName = urlParts[urlParts.length - 1]; - const bucketPath = `${item.user_id}/${fileName}`; - - await supabase.storage - .from('pictures') - .remove([bucketPath]); - } - } - - // Delete from database (cascade will handle relationships) - const { error } = await supabase - .from('pictures') - .delete() - .in('id', allToDelete.map(item => item.id)); - - if (error) throw error; + await deletePictures(idsToDelete); const totalDeleted = allToDelete.length; toast.success(`Deleted ${totalDeleted} version${totalDeleted === 1 ? '' : 's'} successfully`); diff --git a/packages/ui/src/pages/VideoFeedPlayground.tsx b/packages/ui/src/pages/VideoFeedPlayground.tsx index 915a87c9..f361eeeb 100644 --- a/packages/ui/src/pages/VideoFeedPlayground.tsx +++ b/packages/ui/src/pages/VideoFeedPlayground.tsx @@ -14,7 +14,7 @@ import { ArrowLeft } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import { toast } from "sonner"; import { usePostNavigation } from '@/hooks/usePostNavigation'; -import { fetchMediaItems } from '@/lib/db'; +import { fetchMediaItems } from '@/modules/posts/client-pictures'; // Mock data generators for TikTok features we don't have const SAMPLE_AVATARS = [