refactor supabase fuck - pictures | posts
This commit is contained in:
parent
1be7374aae
commit
7004b3f2d1
@ -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"));
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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!'));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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!",
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
366
packages/ui/src/modules/posts/client-pictures.ts
Normal file
366
packages/ui/src/modules/posts/client-pictures.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
|
||||
241
packages/ui/src/modules/posts/client-posts.ts
Normal file
241
packages/ui/src/modules/posts/client-posts.ts
Normal 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();
|
||||
};
|
||||
0
packages/ui/src/modules/posts/types-pictures.ts
Normal file
0
packages/ui/src/modules/posts/types-pictures.ts
Normal file
0
packages/ui/src/modules/posts/types-post.ts
Normal file
0
packages/ui/src/modules/posts/types-post.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
276
packages/ui/src/modules/user/client-user.ts
Normal file
276
packages/ui/src/modules/user/client-user.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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[]>([]);
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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 = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user