refactor supabase fuck - pictures | posts

This commit is contained in:
lovebird 2026-02-17 18:09:40 +01:00
parent 1be7374aae
commit 7004b3f2d1
57 changed files with 1416 additions and 2457 deletions

View File

@ -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"));

View File

@ -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<CreationWizardPopupProps> = ({
// 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<CreationWizardPopupProps> = ({
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<CreationWizardPopupProps> = ({
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();

View File

@ -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 = ({
<T>Edit Picture</T>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="edit" className="flex items-center gap-2">
@ -349,7 +344,7 @@ const EditImageModal = ({
<T>Versions</T>
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
@ -375,7 +370,7 @@ const EditImageModal = ({
)}
</Button>
)}
<FormField
control={form.control}
name="title"
@ -383,8 +378,8 @@ const EditImageModal = ({
<FormItem>
<FormLabel><T>Title (Optional)</T></FormLabel>
<FormControl>
<Input
placeholder={translate("Enter a title...")}
<Input
placeholder={translate("Enter a title...")}
{...field}
onKeyDown={(e) => {
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 = ({
</button>
</div>
<FormControl>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder={translate("Describe your photo... You can use **markdown** formatting!")}
className="min-h-[120px]"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder={translate("Describe your photo... You can use **markdown** formatting!")}
className="min-h-[120px]"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -486,7 +480,7 @@ const EditImageModal = ({
</form>
</Form>
</TabsContent>
<TabsContent value="collections" className="mt-4">
<div className="space-y-4">
{loadingCollections ? (
@ -515,11 +509,10 @@ const EditImageModal = ({
{collections.map((collection) => (
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${
selectedCollections.has(collection.id)
className={`cursor-pointer transition-colors ${selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
}`}
onClick={() => handleToggleCollection(collection.id)}
>
<CardContent className="p-3">
@ -548,21 +541,21 @@ const EditImageModal = ({
))}
</div>
)}
<div className="pt-2 text-sm text-muted-foreground text-center">
<T>
{selectedCollections.size === 0
? 'Not in any collections'
: selectedCollections.size === 1
? 'In 1 collection'
: `In ${selectedCollections.size} collections`}
? 'In 1 collection'
: `In ${selectedCollections.size} collections`}
</T>
</div>
</div>
</TabsContent>
<TabsContent value="versions" className="mt-4">
<VersionSelector
<VersionSelector
currentPictureId={pictureId}
onVersionSelect={onUpdateSuccess}
/>

View File

@ -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 = ({
<div className="flex flex-col gap-12">
{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 (
<MediaCard
key={item.id}
id={item.id}

View File

@ -2,7 +2,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { RotateCw, RotateCcw, Crop as CropIcon, Download, X, Check, Save, Image as ImageIcon, Sliders } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { updatePicture } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/ui/use-toast';
import { uploadImage } from '@/lib/uploadUtils';
@ -91,15 +91,10 @@ export const ImageEditor = ({ imageUrl, pictureId, onSave, onClose }: ImageEdito
const { publicUrl } = await uploadImage(file, user.id);
const { error: dbError } = await supabase
.from('pictures')
.update({
image_url: publicUrl,
updated_at: new Date().toISOString()
})
.eq('id', pictureId);
if (dbError) throw dbError;
await updatePicture(pictureId, {
image_url: publicUrl,
updated_at: new Date().toISOString()
});
toast({ title: "Image Saved", description: "Source image updated successfully." });
onSave?.(publicUrl);

View File

@ -33,7 +33,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { createImage, editImage } from "@/lib/image-router";
import PublishDialog from "@/components/PublishDialog";
import { useOrganization } from "@/contexts/OrganizationContext";
import { runTools } from "@/lib/openai";
import { T, translate } from "@/i18n";
import { DEFAULT_QUICK_ACTIONS, QuickAction as QuickActionType } from "@/constants";
@ -50,7 +50,7 @@ import PostPicker from "@/components/PostPicker";
// Import types and handlers
import { ImageFile, ImageWizardProps } from "./ImageWizard/types";
import { getUserSecrets } from "./ImageWizard/db";
import { getUserApiKeys as getUserSecrets } from "@/modules/user/client-user";
type QuickAction = QuickActionType; // Re-export for compatibility
import {
handleFileUpload,

View File

@ -1,28 +1,21 @@
/**
* Database operations for ImageWizard
* All Supabase queries and mutations isolated here
* Delegates to client-pictures.ts and client-user.ts API layer
*/
import { supabase } from "@/integrations/supabase/client";
import { toast } from "sonner";
import { translate } from "@/i18n";
import {
createPicture,
updatePicture,
fetchPictureById,
uploadFileToStorage,
addCollectionPictures,
} from "@/modules/posts/client-pictures";
import { getUserOpenAIKey } from "@/modules/user/client-user";
/**
* Get organization ID by slug
*/
export const getOrganizationId = async (orgSlug: string): Promise<string | null> => {
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<boolean> => {
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<void> => {
// 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<boolean> => {
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<void> => {
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<void> => {
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<void> => {
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<string | null> => {
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<Record<string, string> | 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<string, any>;
return (settings.api_keys as Record<string, string>) || 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<string, string>): Promise<void> => {
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<string, any>) || {};
const currentApiKeys = (currentSettings.api_keys as Record<string, any>) || {};
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;
}
};

View File

@ -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<string>();
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<string, any>();
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);
}
};

View File

@ -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;
});

View File

@ -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<React.SetStateAction<boolean>>
) => {
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<React.SetStateAction<boolean>>
) => {
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);
}
}

View File

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

View File

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

View File

@ -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<MarkdownEditorProps> = ({
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<string> => {
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<MarkdownEditorProps> = ({
setImagePickerOpen(false);
} catch (error) {
console.error('[handleImageSelect] Error fetching image:', error);
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;

View File

@ -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<MarkdownEditorProps> = ({
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;

View File

@ -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!'));
}

View File

@ -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);
}

View File

@ -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!",

View File

@ -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"));
}

View File

@ -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<VersionSelectorProps> = ({
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<VersionSelectorProps> = ({
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<VersionSelectorProps> = ({
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<VersionSelectorProps> = ({
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<VersionSelectorProps> = ({
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;

View File

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

View File

@ -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';

View File

@ -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<FeedCardProps> = ({
post,
currentUserId,
onLike,
onComment,
onNavigate
}) => {
const navigate = useNavigate();
@ -32,13 +32,6 @@ export const FeedCard: React.FC<FeedCardProps> = ({
const [lastTap, setLastTap] = useState<number>(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<FeedCardProps> = ({
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

View File

@ -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<FilterPanelProps> = ({
}
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<FilterPanelProps> = ({
// 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<FilterPanelProps> = ({
}
};
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);

View File

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

View File

@ -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';

View File

@ -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';

View File

@ -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<GalleryWidgetProps> = ({
setLoading(true);
try {
const items = await fetchMediaItemsByIds(pictureIds, { maintainOrder: true });
// Transform to PostMediaItem format
@ -93,7 +90,7 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
visible: true,
is_selected: false,
comments: [{ count: 0 }]
})) as PostMediaItem[];
})) as any[];
setMediaItems(postMediaItems);
@ -197,7 +194,6 @@ const GalleryWidget: React.FC<GalleryWidgetProps> = ({
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<GalleryWidgetProps> = ({
// 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);

View File

@ -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<ImagePickerDialogProps> = ({
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(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<ImagePickerDialogProps> = ({
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<string>();
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<ImagePickerDialogProps> = ({
}
};
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<string>();
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<ImagePickerDialogProps> = ({
}
};
console.log('selectedIds', selectedIds);
console.log('selectedId', selectedId);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">

View File

@ -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<MarkdownTextWidgetEditProps> = ({
}
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<MarkdownTextWidgetEditProps> = ({
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<MarkdownTextWidgetEditProps> = ({
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<MarkdownTextWidgetEditProps> = ({
}
}
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;

View File

@ -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<PhotoCardWidgetProps> = ({
imageFit = 'cover',
onPropsChange
}) => {
const { user } = useAuth();
const [pictureId, setPictureId] = useState<string | null>(propPictureId);
const [picture, setPicture] = useState<Picture | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
@ -107,33 +109,17 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
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<PhotoCardWidgetProps> = ({
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<PhotoCardWidgetProps> = ({
// 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

View File

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

View File

@ -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);

View File

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

View File

@ -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<string | null> => {
try {

View File

@ -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<string, string> = {};
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<MediaItem[]> => {
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<any[]> {
// 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<MediaItem[]> {
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<string>;
videoLikes: Set<string>;
}> {
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<UserProfile | null> => {
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<PostMediaItem>, 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<PostMediaItem>, 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<PostMediaItem>[], 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<string, string>): Promise<void> => {
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<string, any>) || {};
const currentApiKeys = (currentSettings.api_keys as Record<string, any>) || {};
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<Record<string, any> | 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<string, any>;
return (settings.variables as Record<string, any>) || {};
} 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<string, any>): Promise<void> => {
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<string, any>) || {};
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;
}
};

View File

@ -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',

View File

@ -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 <img src={fileUrl} alt={node.name} loading="lazy" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />;
}
@ -149,7 +151,7 @@ const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--b
const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }> = (props) => {
const {
mount = 'test',
mount = 'root',
path: initialPath = '/',
glob = '*.*',
mode = 'simple',
@ -158,6 +160,9 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }>
showToolbar = true,
} = props;
const { session } = useAuth();
const accessToken = session?.access_token;
const [currentPath, setCurrentPath] = useState(initialPath);
const [nodes, setNodes] = useState<INode[]>([]);
const [loading, setLoading] = useState(false);
@ -201,7 +206,9 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }>
? `/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<string, string> = {};
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<FileBrowserWidgetProps & { variables?: any }>
} 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 <img>/<video> src (can't set headers on HTML elements)
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
// ── Sorted items (with optional ".." at index 0) ────────────
const canGoUp = currentPath !== '/' && currentPath !== '';
@ -247,7 +257,10 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }>
setCurrentPath(parts.length ? parts.join('/') : '/');
}, [currentPath, canGoUp]);
const getFileUrl = (node: INode) => `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`;
const getFileUrl = (node: INode) => {
const base = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`;
return tokenParam ? `${base}?${tokenParam}` : base;
};
const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); };
const handleDownload = () => {
@ -599,7 +612,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }>
borderStyle: isSelected ? 'outset' : 'solid',
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
}}>
{isDir ? <NodeIcon node={node} size={28} /> : <ThumbPreview node={node} mount={mount} />}
{isDir ? <NodeIcon node={node} size={28} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} />}
<span style={{
fontSize: 14, textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',

View File

@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { T, translate } from "@/i18n";
import { fetchUserPage } from "@/lib/db";
import { mergePageVariables } from "@/lib/page-variables";
import { GenericCanvas } from "@/modules/layout/GenericCanvas";
@ -29,6 +28,7 @@ import { SEO } from "@/components/SEO";
const UserPageEdit = lazy(() => import("./editor/UserPageEdit"));
import { Page, UserProfile } from "./types";
import { fetchUserPage } from "./client-pages";
interface UserPageProps {
userId?: string;

View File

@ -1,10 +1,31 @@
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 { fetchWithDeduplication } from "@/lib/db";
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 fetchPageById = async (id: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`page-${id}`, async () => {

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { TypeDefinition, fetchTypes } from '@/modules/types/client-types';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/lib/schema-utils';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/modules/types/schema-utils';
import { customWidgets, customTemplates } from '@/modules/types/RJSFTemplates';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

View File

@ -0,0 +1,366 @@
import { supabase as defaultSupabase, supabase } 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 { fetchWithDeduplication } from "@/lib/db";
import { FetchMediaOptions } from "@/utils/mediaUtils";
export const fetchVersions = async (mediaItem: PostMediaItem, userId?: string, client?: SupabaseClient) => {
const key = `versions-${mediaItem.id}-${userId || 'anon'}`;
return fetchWithDeduplication(key, async () => {
const rootId = mediaItem.parent_id || mediaItem.id;
const params = new URLSearchParams({ rootId, types: 'null,supabase-image' });
if (userId) params.append('userId', userId);
if (mediaItem.user_id) params.append('ownerId', mediaItem.user_id);
const res = await fetch(`/api/pictures/versions?${params}`);
if (!res.ok) throw new Error(`Failed to fetch versions: ${res.statusText}`);
return await res.json();
});
};
export const createPicture = async (picture: Partial<PostMediaItem>, 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<PostMediaItem>, 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 deletePictures = async (ids: string[]) => {
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/delete-batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ids })
});
if (!response.ok) {
throw new Error(`Failed to delete pictures: ${response.statusText}`);
}
return await response.json();
};
/** Fetch pictures with optional filters */
export const fetchPictures = async (options: { userId?: string; limit?: number } = {}) => {
const params = new URLSearchParams();
if (options.userId) params.append('userId', options.userId);
params.append('limit', String(options.limit ?? 9999));
const res = await fetch(`/api/pictures?${params}`);
if (!res.ok) throw new Error(`Failed to fetch pictures: ${res.statusText}`);
const result = await res.json();
return (result.data || []) as any[];
};
/** Convenience alias: fetch all pictures for a user */
export const fetchUserPictures = (userId: string) => fetchPictures({ userId });
/** Convenience alias: fetch N recent pictures */
export const fetchRecentPictures = (limit: number = 50) => fetchPictures({ limit });
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 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;
}
};
/**
* Fetches and merges pictures and videos from the database via API
* Returns a unified array of media items sorted by created_at
*/
export async function fetchMediaItems(options: FetchMediaOptions = {}): Promise<MediaItem[]> {
const { organizationId, includePrivate = false, limit, userId, tag } = options;
try {
const params = new URLSearchParams();
params.append('limit', '9999'); // Get all, we filter client-side
if (userId) params.append('userId', userId);
if (tag) params.append('tag', tag);
const res = await fetch(`/api/pictures?${params}`);
if (!res.ok) throw new Error(`Failed to fetch pictures: ${res.statusText}`);
const result = await res.json();
let pictures = (result.data || []) as any[];
// Apply client-side filters not supported by the API
pictures = pictures.filter((p: any) => p.is_selected && p.visible);
if (organizationId !== undefined) {
if (organizationId === null) {
pictures = pictures.filter((p: any) => !p.organization_id);
} else {
pictures = pictures.filter((p: any) => p.organization_id === organizationId);
}
}
// Filter private collections if needed
const publicPictures = includePrivate
? pictures
: await filterPrivateCollectionPictures(pictures);
// Convert to unified MediaItem format
const allMedia: MediaItem[] = (publicPictures.map(p => {
const isLegacyVideo = p.type === 'video-intern';
return {
id: p.id,
type: isLegacyVideo ? 'video' : 'picture',
title: p.title,
description: p.description,
url: p.image_url,
thumbnail_url: p.thumbnail_url,
likes_count: p.likes_count || 0,
created_at: p.created_at,
user_id: p.user_id,
comments_count: 0,
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<string>;
videoLikes: Set<string>;
}> {
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 fetchPictureById = async (id: string, client?: SupabaseClient) => {
return fetchWithDeduplication(`picture-${id}`, async () => {
const res = await fetch(`/api/pictures/${id}`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch picture: ${res.statusText}`);
}
return await res.json();
});
};
/**
* 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<MediaItem[]> => {
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<any[]> {
// TODO: Implement actual privacy filtering logic based on collections
return pictures;
}
export const unlinkPictures = async (ids: 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 res = await fetch('/api/pictures/unlink', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ids })
});
if (!res.ok) throw new Error(`Failed to unlink pictures: ${res.statusText}`);
};
export const upsertPictures = async (pictures: Partial<PostMediaItem>[], client?: SupabaseClient) => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error('No active session');
const res = await fetch('/api/pictures/upsert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ pictures })
});
if (!res.ok) throw new Error(`Failed to upsert pictures: ${res.statusText}`);
};
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) => {
if (rootIds.length === 0) return [];
const sortedIds = [...rootIds].sort();
const key = `selected-versions-${sortedIds.join(',')}`;
return fetchWithDeduplication(key, async () => {
const params = new URLSearchParams({ rootIds: rootIds.join(',') });
const res = await fetch(`/api/pictures/versions?${params}`);
if (!res.ok) throw new Error(`Failed to fetch selected versions: ${res.statusText}`);
return await res.json();
});
};

View File

@ -0,0 +1,241 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { UserProfile } from "@/pages/Post/types";
import { MediaType, MediaItem } from "@/types";
import { SupabaseClient } from "@supabase/supabase-js";
import { fetchWithDeduplication } from "@/lib/db";
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)
}
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<string, string> = {};
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 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 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();
};
// 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;
});
};
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();
};

View File

@ -3,9 +3,8 @@ import { WidgetProps } from '@rjsf/utils';
import { Button } from '@/components/ui/button';
import { Image as ImageIcon, X, RefreshCw } from 'lucide-react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { supabase } from '@/integrations/supabase/client';
import MediaCard from '@/components/MediaCard';
import { MediaType } from '@/lib/mediaRegistry';
import { fetchPictureById } from '@/modules/posts/client-pictures';
export const ImageWidget = (props: WidgetProps) => {
const {
@ -35,17 +34,10 @@ export const ImageWidget = (props: WidgetProps) => {
setLoading(true);
try {
const { data, error } = await supabase
.from('pictures')
.select('*')
.eq('id', value)
.single();
if (error) throw error;
const data = await fetchPictureById(value);
setPicture(data);
} catch (error) {
console.error("Error fetching picture:", error);
// Keep the value but maybe show error state?
} finally {
setLoading(false);
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import type { WidgetProps, RegistryWidgetsType } from '@rjsf/utils';
import CollapsibleSection from '../../components/CollapsibleSection';
import CollapsibleSection from '@/components/CollapsibleSection';
// Utility function to convert camelCase to Title Case
const formatLabel = (str: string): string => {
@ -259,7 +259,7 @@ export const ObjectFieldTemplate = (props: any) => {
};
// Custom widgets
import { ImageWidget } from '@/components/widgets/ImageWidget';
import { ImageWidget } from '@/modules/types/ImageWidget';
export const customWidgets: RegistryWidgetsType = {
TextWidget,

View File

@ -10,7 +10,7 @@ import { customWidgets, customTemplates } from './RJSFTemplates';
import { generateRandomData } from './randomDataGenerator';
import { toast } from 'sonner';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/lib/schema-utils';
import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } from '@/modules/types/schema-utils';
import { TypeDefinition } from './client-types';
export interface TypeRendererRef {

View File

@ -0,0 +1,276 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { UserProfile } from "@/pages/Post/types";
import { SupabaseClient } from "@supabase/supabase-js";
import { fetchWithDeduplication } from "@/lib/db";
/** Fetch full profile data from server API endpoint */
export const fetchProfileAPI = async (userId: string): Promise<{ profile: any; recentPosts: any[] } | null> => {
const res = await fetch(`/api/profile/${userId}`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch profile: ${res.statusText}`);
}
return await res.json();
};
export const fetchAuthorProfile = async (userId: string, client?: SupabaseClient): Promise<UserProfile | null> => {
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 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;
});
}
/** Get all API keys from user_secrets.settings.api_keys */
export const getUserApiKeys = async (userId: string, client?: SupabaseClient): Promise<Record<string, string> | null> => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`api-keys-${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 as Record<string, string>) || null;
});
};
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 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);
});
};
/**
* Update user secrets in user_secrets table (settings column)
*/
export const updateUserSecrets = async (userId: string, secrets: Record<string, string>): Promise<void> => {
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<string, any>) || {};
const currentApiKeys = (currentSettings.api_keys as Record<string, any>) || {};
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<Record<string, any> | 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<string, any>;
return (settings.variables as Record<string, any>) || {};
} 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<string, any>): Promise<void> => {
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<string, any>) || {};
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;
}
};

View File

@ -7,16 +7,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import MarkdownEditor from '@/components/MarkdownEditorEx';
import AITextGenerator from '@/components/AITextGenerator';
import { getUserSecrets } from '@/components/ImageWizard/db';
import { getUserApiKeys as getUserSecrets } from '@/modules/user/client-user';
import { useAuth } from '@/hooks/useAuth';
import { generateText, transcribeAudio, runTools } from '@/lib/openai';
import { supabase } from '@/integrations/supabase/client';
import { createMarkdownToolPreset } from '@/lib/markdownImageTools';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import OpenAI from 'openai';
import SimpleLogViewer from '@/components/SimpleLogViewer';
import { getUserSettings, updateUserSettings, getProviderConfig } from '@/lib/db';
import { getUserSettings, updateUserSettings, getProviderConfig } from '@/modules/user/client-user';
import { usePromptHistory } from '@/hooks/usePromptHistory';
const PlaygroundEditorLLM: React.FC = () => {

View File

@ -8,10 +8,12 @@ import { usePostNavigation } from "@/hooks/usePostNavigation";
import { useWizardContext } from "@/hooks/useWizardContext";
import { T, translate } from "@/i18n";
import { isVideoType } from "@/lib/mediaRegistry";
import { getYouTubeId, getTikTokId, updateMediaPositions, getVideoUrlWithResolution } from "./Post/utils";
import { YouTubeDialog } from "./Post/components/YouTubeDialog";
import { TikTokDialog } from "./Post/components/TikTokDialog";
import { ArticleRenderer } from "./Post/renderers/ArticleRenderer";
import UserPage from "@/modules/pages/UserPage";
import { ThumbsRenderer } from "./Post/renderers/ThumbsRenderer";
import { CompactRenderer } from "./Post/renderers/CompactRenderer";
@ -27,10 +29,11 @@ import '@vidstack/react/player/styles/default/layouts/video.css';
// New Modules
import { PostMediaItem as MediaItem, PostItem, UserProfile } from "./Post/types";
import * as db from "./Post/db";
import { ImageFile, MediaType } from "@/types";
import { uploadInternalVideo } from "@/utils/uploadUtils";
import { fetchPageById } from "@/modules/pages/client-pages";
import { addCollectionPictures, createPicture, fetchPictureById, fetchVersions, toggleLike, unlinkPictures, updateStorageFile, uploadFileToStorage, upsertPictures } from "@/modules/posts/client-pictures";
import { fetchFullPost, fetchPostById, updatePostDetails } from "@/modules/posts/client-posts";
// Heavy Components - Lazy Loaded
@ -81,9 +84,9 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
const isVideo = isVideoType(mediaItem?.type as MediaType);
// Initialize viewMode from URL parameter
const [viewMode, setViewMode] = useState<'compact' | 'article' | 'thumbs'>(() => {
const [viewMode, setViewMode] = useState<'compact' | 'thumbs'>(() => {
const viewParam = searchParams.get('view');
if (viewParam === 'article' || viewParam === 'compact' || viewParam === 'thumbs') {
if (viewParam === 'compact' || viewParam === 'thumbs') {
return viewParam;
}
return 'compact';
@ -120,7 +123,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
useEffect(() => {
const viewParam = searchParams.get('view');
if (viewParam === 'article' || viewParam === 'compact' || viewParam === 'thumbs') {
if (viewParam === 'compact' || viewParam === 'thumbs') {
setViewMode(viewParam as any);
}
}, [searchParams]);
@ -147,14 +150,14 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
useEffect(() => {
const savedMode = localStorage.getItem('postViewMode');
if (savedMode === 'compact' || savedMode === 'article' || savedMode === 'thumbs') {
if (savedMode === 'compact' || savedMode === 'thumbs') {
setViewMode(savedMode as any);
} else if (post?.settings?.display) {
setViewMode(post.settings.display);
}
}, [post]);
const handleViewMode = (mode: 'compact' | 'article' | 'thumbs') => {
const handleViewMode = (mode: 'compact' | 'thumbs') => {
setViewMode(mode);
// LocalStorage backup removed to favor URL matching
// localStorage.setItem('postViewMode', mode);
@ -184,7 +187,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
const uploadData = await uploadInternalVideo(file, user.id);
// Fetch the created picture record to get the correct URL and details
const picture = await db.fetchPictureById(uploadData.dbId);
const picture = await fetchPictureById(uploadData.dbId);
if (!picture) throw new Error('Failed to retrieve uploaded video details');
const newItem = {
@ -204,7 +207,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
} else {
// Handle regular image upload to storage
const publicUrl = await db.uploadFileToStorage(user.id, file);
const publicUrl = await uploadFileToStorage(user.id, file);
const newItem = {
id: crypto.randomUUID(),
@ -270,7 +273,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
setShowGalleryPicker(false);
toast.info("Adding image from gallery...");
try {
const picture = await db.fetchPictureById(pictureId);
const picture = await fetchPictureById(pictureId);
if (!picture) return;
const newItem = {
@ -316,13 +319,13 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
if (!localPost || !post) return;
toast.promise(
async () => {
await db.updatePostDetails(post.id, {
await updatePostDetails(post.id, {
title: localPost.title,
description: localPost.description,
});
if (removedItemIds.size > 0) {
await db.unlinkPictures(Array.from(removedItemIds));
await unlinkPictures(Array.from(removedItemIds));
}
const updates = localMediaItems.map((item, index) => ({
@ -338,7 +341,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
thumbnail_url: item.thumbnail_url
}));
await db.upsertPictures(updates);
await upsertPictures(updates);
setRemovedItemIds(new Set());
setTimeout(() => window.location.reload(), 500);
@ -398,7 +401,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
const loadVersions = async () => {
if (!mediaItem || isVideo) return;
try {
const allImages = await db.fetchVersions(mediaItem, user?.id) as any[];
const allImages = await fetchVersions(mediaItem, user?.id) as any[];
const parentImage = allImages.find(img => !img.parent_id) || mediaItem;
const imageFiles: ImageFile[] = allImages.map(img => ({
path: img.id,
@ -471,7 +474,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
// Versions and likes are resolved server-side now
try {
const postData = await db.fetchPostById(id!);
const postData = await fetchPostById(id!);
if (postData) {
let items = (postData.pictures as any[]).map((p: any) => ({
...p,
@ -513,10 +516,10 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
return;
}
const pictureData = await db.fetchPictureById(id!);
const pictureData = await fetchPictureById(id!);
if (pictureData) {
if (pictureData.post_id) {
const fullPostData = await db.fetchFullPost(pictureData.post_id);
const fullPostData = await fetchFullPost(pictureData.post_id);
if (fullPostData) {
let items = (fullPostData.pictures as any[]).map((p: any) => ({
...p,
@ -685,7 +688,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
return;
}
try {
const isNowLiked = await db.toggleLike(user.id, mediaItem.id, isLiked);
const isNowLiked = await toggleLike(user.id, mediaItem.id, isLiked);
setIsLiked(isNowLiked);
setLikesCount(prev => isNowLiked ? prev + 1 : prev - 1);
@ -725,7 +728,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
const urlParts = currentImageUrl.split('/');
const fileName = urlParts[urlParts.length - 1];
const bucketPath = `${mediaItem.user_id}/${fileName}`;
await db.updateStorageFile(bucketPath, blob);
await updateStorageFile(bucketPath, blob);
toast.success(translate('Image updated successfully!'));
fetchMedia();
} else {
@ -733,8 +736,8 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
return;
}
} else if (option === 'version') {
const publicUrl = await db.uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-version.png`);
const pictureData = await db.createPicture({
const publicUrl = await uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-version.png`);
const pictureData = await createPicture({
title: newTitle?.trim() || null,
description: description || `Generated from: ${mediaItem.title}`,
image_url: publicUrl,
@ -744,20 +747,20 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
visible: false
});
if (collectionIds && collectionIds.length > 0 && pictureData) {
await db.addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id })));
await addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id })));
}
toast.success(translate('Version saved successfully!'));
loadVersions();
} else {
const publicUrl = await db.uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-generated.png`);
const pictureData = await db.createPicture({
const publicUrl = await uploadFileToStorage(user.id, blob, `${user.id}/${Date.now()}-generated.png`);
const pictureData = await createPicture({
title: newTitle?.trim() || null,
description: description || `Generated from: ${mediaItem.title}`,
image_url: publicUrl,
user_id: user.id
});
if (collectionIds && collectionIds.length > 0 && pictureData) {
await db.addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id })));
await addCollectionPictures(collectionIds.map(collectionId => ({ collection_id: collectionId, picture_id: pictureData.id })));
}
toast.success(translate('Image published to gallery!'));
}
@ -917,9 +920,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
)}
<div className={embedded ? "w-full h-[inherit]" : "w-full max-w-[1600px] mx-auto"}>
{viewMode === 'article' ? (
<ArticleRenderer {...rendererProps} mediaItem={mediaItem} />
) : viewMode === 'thumbs' ? (
{viewMode === 'thumbs' ? (
<ThumbsRenderer {...rendererProps} />
) : (
<CompactRenderer {...rendererProps} mediaItem={mediaItem} />

View File

@ -4,7 +4,7 @@ import { translate } from "@/i18n";
import { transcribeAudio } from "@/lib/openai";
import { editImage } from "@/image-api";
import { PostMediaItem } from "./types";
import { getUserSettings, updateUserSettings, getUserOpenAIKey } from "./db";
import { getUserSettings, updateUserSettings, getUserOpenAIKey } from "@/modules/user/client-user";
export const usePostLLM = (user: any, mediaItem: PostMediaItem | null) => {
const [promptTemplates, setPromptTemplates] = useState<any[]>([]);

View File

@ -1,11 +1,14 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { invalidateServerCache, deletePost, updatePostMeta } from '@/lib/db';
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { MediaItem, PostItem, User } from "@/types";
import { MediaItem } from "@/types";
import { translate } from "@/i18n";
import { updateMediaPositions } from "./utils";
import { PostItem } from './types';
import { User } from '@supabase/supabase-js';
import { deletePost, updatePostMeta } from '@/modules/posts/client-posts';
import { deletePictures, updatePicture } from '@/modules/posts/client-pictures';
interface UsePostActionsProps {
post: PostItem | null;
@ -41,9 +44,7 @@ export const usePostActions = ({
if (!post) return;
try {
await deletePost(post.id);
toast.success(translate('Post deleted'));
await invalidateServerCache(['posts']);
navigate('/');
} catch (error) {
console.error('Error deleting post:', error);
@ -56,41 +57,14 @@ export const usePostActions = ({
const handleDeletePicture = async () => {
if (!mediaItem) return;
try {
const mediaUrl = mediaItem.image_url;
const { error } = await supabase
.from('pictures')
.delete()
.eq('id', mediaItem.id);
if (error) throw error;
// Delete from storage if hosted on Supabase
if (mediaUrl.includes('supabase.co/storage/')) {
const urlParts = mediaUrl.split('/');
const fileName = urlParts[urlParts.length - 1];
const bucketPath = `${mediaItem.user_id}/${fileName}`;
const { error: storageError } = await supabase.storage
.from('pictures')
.remove([bucketPath]);
if (storageError) {
console.error("Error deleting file from storage:", storageError);
}
}
// Delete via API (handles DB + storage cleanup)
await deletePictures([mediaItem.id]);
toast.success(translate('Picture deleted'));
// Navigate or refresh
// If it was the only item, maybe delete post? Or just refresh
// If it's part of a post, fetchMedia will handle updating list
if (mediaItems.length <= 1 && post) {
// If it was the last picture of a post, maybe redirect to home or refresh?
// Existing logic just refreshed or navigated.
// We'll mimic fetching.
navigate(`/`); // Fallback if post is empty? check existing logic
navigate(`/`);
} else {
// Navigate to another item?
// Post.tsx handled this by fetchMedia and effect probably.
fetchMedia();
}
@ -108,18 +82,14 @@ export const usePostActions = ({
const newItems = mediaItems.filter(i => i.id !== item.id);
setMediaItems(newItems);
const { error } = await supabase
.from('pictures')
.update({
post_id: null,
position: -1 // Reset position
})
.eq('id', item.id);
await updatePicture(item.id, {
post_id: null,
position: -1
} as any);
if (error) throw error;
toast.success(translate('Image removed from post'));
// Re-index remaining items
await updateMediaPositions(newItems, setMediaItems, setMediaItems); // Using same setter for both generic/local here if not editing
await updateMediaPositions(newItems, setMediaItems, setMediaItems);
} catch (error) {
console.error('Error unlinking image:', error);
toast.error(translate('Failed to remove image'));

View File

@ -3,20 +3,21 @@ import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import ImageGallery from "@/components/ImageGallery";
import { ImageFile } from "@/types";
import { fetchUserPictures, deletePicture } from "@/modules/posts/client-pictures";
import { toast } from "sonner";
import { Navigate, Link, useNavigate } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { User, Images, Save, Camera, Upload, Check, Building2, Key, Globe, Hash } from "lucide-react";
import { User, Images, Save, Camera, Upload, Check, Key, Globe, Hash } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { T, translate, getCurrentLang, supportedLanguages, setLanguage } from "@/i18n";
import { uploadImage } from '@/lib/uploadUtils';
import { getUserSecrets, updateUserSecrets, getUserVariables, updateUserVariables } from '@/lib/db';
import { getUserSecrets, updateUserSecrets, getUserVariables, updateUserVariables } from '@/modules/user/client-user';
import { VariablesEditor } from '@/components/variables/VariablesEditor';
import {
Sidebar,
@ -31,7 +32,7 @@ import {
useSidebar
} from "@/components/ui/sidebar";
type ActiveSection = 'general' | 'organizations' | 'api-keys' | 'variables' | 'gallery';
type ActiveSection = 'general' | 'api-keys' | 'variables' | 'gallery';
const Profile = () => {
const { user, loading } = useAuth();
@ -55,54 +56,16 @@ const Profile = () => {
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [selectedLanguage, setSelectedLanguage] = useState(getCurrentLang());
const [userOrganizations, setUserOrganizations] = useState<Array<{
id: string;
role: string;
organization: {
id: string;
name: string;
slug: string;
};
}>>([]);
const [loadingOrgs, setLoadingOrgs] = useState(false);
useEffect(() => {
if (user) {
fetchUserImages();
fetchProfile();
fetchUserOrganizations();
setEmail(user.email || '');
}
}, [user]);
const fetchUserOrganizations = async () => {
if (!user) return;
setLoadingOrgs(true);
try {
const { data, error } = await supabase
.from('user_organizations')
.select(`
id,
role,
organization:organizations (
id,
name,
slug
)
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setUserOrganizations(data || []);
} catch (error) {
console.error('Error fetching user organizations:', error);
toast.error(translate('Failed to load organizations'));
} finally {
setLoadingOrgs(false);
}
};
const fetchProfile = async () => {
try {
@ -221,21 +184,16 @@ const Profile = () => {
const fetchUserImages = async () => {
try {
const { data: pictures, error } = await supabase
.from('pictures')
.select('*')
.eq('user_id', user?.id)
.eq('is_selected', true)
.order('created_at', { ascending: false });
const pictures = await fetchUserPictures(user!.id);
// Filter client-side for is_selected (API returns all)
const selected = pictures.filter((p: any) => p.is_selected);
if (error) throw error;
const imageFiles: ImageFile[] = pictures?.map(picture => ({
const imageFiles: ImageFile[] = selected.map((picture: any) => ({
path: picture.title,
src: picture.image_url,
isGenerated: false,
selected: false
})) || [];
}));
setImages(imageFiles);
} catch (error) {
@ -252,43 +210,16 @@ const Profile = () => {
const imageToDelete = images.find(img => img.path === imagePath);
if (!imageToDelete) return;
// Get the picture from the database using the title and user_id
const { data: picture, error: fetchError } = await supabase
.from('pictures')
.select('*')
.eq('title', imagePath)
.eq('user_id', user?.id)
.single();
// Find the picture ID from our fetched data by matching title
const allPics = await fetchUserPictures(user!.id);
const picture = allPics.find((p: any) => p.title === imagePath);
if (fetchError) {
console.error('Error finding picture:', fetchError);
if (!picture) {
toast.error(translate('Failed to find image to delete'));
return;
}
// Delete from storage first
const fileName = picture.image_url.split('/').pop();
if (fileName) {
const { error: storageError } = await supabase.storage
.from('pictures')
.remove([`${user?.id}/${fileName}`]);
if (storageError) {
console.error('Error deleting from storage:', storageError);
}
}
// Delete from database
const { error: dbError } = await supabase
.from('pictures')
.delete()
.eq('id', picture.id);
if (dbError) {
console.error('Error deleting from database:', dbError);
toast.error(translate('Failed to delete image'));
return;
}
await deletePicture(picture.id);
// Update local state
setImages(prevImages => prevImages.filter(img => img.path !== imagePath));
@ -500,72 +431,7 @@ const Profile = () => {
</Card>
)}
{activeSection === 'organizations' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>My Organizations</CardTitle>
<Button size="sm" onClick={() => navigate('/organizations')}>
<Building2 className="mr-2 h-4 w-4" />
Manage Organizations
</Button>
</div>
</CardHeader>
<CardContent>
{loadingOrgs ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading organizations...</div>
</div>
) : userOrganizations.length === 0 ? (
<div className="text-center py-8">
<Building2 className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground mb-4">You're not part of any organizations yet</p>
<Button onClick={() => navigate('/organizations')}>
Browse Organizations
</Button>
</div>
) : (
<div className="space-y-3">
{userOrganizations.map((userOrg) => (
<div
key={userOrg.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-primary flex items-center justify-center">
<Building2 className="h-5 w-5 text-white" />
</div>
<div>
<Link
to={`/org/${userOrg.organization.slug}`}
className="font-medium hover:text-primary transition-colors"
>
{userOrg.organization.name}
</Link>
<p className="text-sm text-muted-foreground">
/{userOrg.organization.slug}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm px-3 py-1 rounded-full bg-primary/10 text-primary font-medium">
{userOrg.role}
</span>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/org/${userOrg.organization.slug}`)}
>
View
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{activeSection === 'api-keys' && (
<Card>
@ -746,7 +612,7 @@ const ProfileSidebar = ({
const menuItems = [
{ id: 'general' as ActiveSection, label: translate('General'), icon: User },
{ id: 'organizations' as ActiveSection, label: translate('Organizations'), icon: Building2 },
{ id: 'api-keys' as ActiveSection, label: translate('API Keys'), icon: Key },
{ id: 'variables' as ActiveSection, label: translate('Variables'), icon: Hash },
];

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { fetchProfileAPI } from "@/modules/user/client-user";
import { useAuth } from "@/hooks/useAuth";
import PhotoGrid from "@/components/PhotoGrid";
import PageManager from "@/modules/pages/PageManager";
@ -11,8 +11,8 @@ import { ThemeToggle } from "@/components/ThemeToggle";
import UserPictures from "@/components/UserPictures";
import { T, translate } from "@/i18n";
import { useFeedData } from "@/hooks/useFeedData";
import * as db from "@/lib/db";
import { SEO } from "@/components/SEO";
import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts";
interface UserProfile {
id: string;
@ -32,12 +32,7 @@ interface Collection {
created_at: string;
}
interface Organization {
id: string;
name: string;
slug: string;
role: string;
}
const UserProfile = () => {
const { userId, orgSlug } = useParams<{ userId: string; orgSlug?: string }>();
@ -50,7 +45,6 @@ const UserProfile = () => {
const [hiddenPosts, setHiddenPosts] = useState<any[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [activeTab, setActiveTab] = useState<'posts' | 'hidden' | 'pictures'>('posts');
const isOwnProfile = currentUser?.id === userId;
@ -68,51 +62,32 @@ const UserProfile = () => {
useEffect(() => {
if (userId) {
fetchUserProfile();
fetchUserCollections();
fetchUserStats();
// fetchUserCollections();
}
}, [userId]); // Removed userProfile and currentUser from deps to prevent loop
}, [userId]);
useEffect(() => {
if (feedPosts) {
// Separate into Public/Listed vs Private (Hidden)
// Note: feedPosts are already FeedPost objects, map them or use as is?
// PhotoGrid expects MediaItemType via mapFeedPostsToMediaItems, but it handles FeedPost[] via customPictures?
// Actually customPictures expects MediaItemType[].
// We need to map them.
const mediaItems = db.mapFeedPostsToMediaItems(feedPosts, 'latest');
const publicAndListed = mediaItems.filter((p: any) => !p.meta?.visibility || p.meta?.visibility !== 'private');
// Need to check where visibility is stored. FeedPost has settings.
// mapFeedPostsToMediaItems might lose settings?
// Let's check mapFeedPostsToMediaItems. It maps 'meta' from 'cover.meta'.
// Visibility is usually on the post 'settings'. mapFeedPostsToMediaItems doesn't seem to pass 'settings' or 'visibility' explicitly?
// We might need to filter on feedPosts first then map.
const publicFeed = feedPosts.filter(p => !p.settings?.visibility || p.settings.visibility !== 'private');
const hiddenFeed = feedPosts.filter(p => p.settings?.visibility === 'private');
setPublicPosts(db.mapFeedPostsToMediaItems(publicFeed, 'latest'));
setHiddenPosts(db.mapFeedPostsToMediaItems(hiddenFeed, 'latest'));
setPublicPosts(mapFeedPostsToMediaItems(publicFeed, 'latest'));
setHiddenPosts(mapFeedPostsToMediaItems(hiddenFeed, 'latest'));
setStats({
public: publicFeed.length,
hidden: hiddenFeed.length,
total: feedPosts.length
});
}
}, [feedPosts]);
const fetchUserProfile = async () => {
try {
// Fetch profile with user_roles
console.log('Fetching profile for user:', userId);
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select(`
*,
user_roles (role)
`)
.eq('user_id', userId)
.maybeSingle();
const result = await fetchProfileAPI(userId!);
if (profileError && profileError.code !== 'PGRST116') {
throw profileError;
}
const profile = result?.profile;
const newProfile = profile ? {
id: profile.user_id,
@ -131,37 +106,6 @@ const UserProfile = () => {
};
setUserProfile(prev => JSON.stringify(prev) !== JSON.stringify(newProfile) ? newProfile : prev);
// Fetch user organizations separately (no direct FK from profiles to user_organizations)
if (userId) {
const { data: userOrgs, error: orgsError } = await supabase
.from('user_organizations')
.select(`
role,
organizations (
id,
name,
slug
)
`)
.eq('user_id', userId);
if (orgsError) {
console.error('Error fetching organizations:', orgsError);
} else {
const orgs = (userOrgs || []).map((item: any) => ({
id: item.organizations?.id,
name: item.organizations?.name,
slug: item.organizations?.slug,
role: item.role
})).filter((o: any) => o.id); // Filter out invalid
setOrganizations(orgs);
}
} else {
setOrganizations([]);
}
} catch (error) {
console.error('Error fetching user profile:', error);
toast.error(translate('Failed to load user profile'));
@ -169,48 +113,6 @@ const UserProfile = () => {
}
};
const fetchUserStats = async () => {
try {
const { count: publicCount } = await supabase
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.or('settings.is.null,settings->>visibility.eq.public');
const { count: hiddenCount } = await supabase
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('settings->>visibility', 'private');
setStats({
public: publicCount || 0,
hidden: hiddenCount || 0,
total: (publicCount || 0) + (hiddenCount || 0)
});
} catch (e) {
console.error("Error fetching stats", e);
}
};
const fetchUserCollections = async () => {
try {
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error fetching user collections:', error);
}
};
if (!userProfile) {
return (
@ -385,35 +287,7 @@ const UserProfile = () => {
</div>
</div>
{/* Organizations Section */}
{organizations.length > 0 && (
<div className="border-t pt-6">
<h3 className="text-sm font-semibold mb-4 px-4 md:px-0"><T>Organizations</T></h3>
<div className="flex gap-4 overflow-x-auto pb-2 px-4 md:px-0">
{organizations.map((org) => (
<Link
key={org.id}
to={`/org/${org.slug}`}
className="flex flex-col items-center gap-2 min-w-0 group"
>
<div className="w-16 h-16 rounded-full border-2 border-primary/30 bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center group-hover:scale-105 transition-transform">
<span className="text-lg font-semibold text-primary">
{org.name.charAt(0).toUpperCase()}
</span>
</div>
<div className="text-center">
<span className="text-xs font-medium group-hover:text-primary transition-colors block max-w-[80px] truncate">
{org.name}
</span>
<span className="text-[10px] text-muted-foreground capitalize">
{org.role}
</span>
</div>
</Link>
))}
</div>
</div>
)}
{/* Pages Section */}
<div className="border-t p-2">

View File

@ -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`);

View File

@ -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 = [