supbase
This commit is contained in:
parent
6a1ef38863
commit
0ba36fb1d3
@ -25,7 +25,6 @@ const GlobalDragDrop = React.lazy(() => import("@/components/GlobalDragDrop"));
|
||||
registerAllWidgets();
|
||||
|
||||
import Index from "./pages/Index";
|
||||
import Auth from "./pages/Auth";
|
||||
import AuthZ from "./pages/AuthZ";
|
||||
|
||||
const UpdatePassword = React.lazy(() => import("./pages/UpdatePassword"));
|
||||
@ -74,8 +73,6 @@ TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground"));
|
||||
if (enablePlaygrounds) {
|
||||
PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor"));
|
||||
PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM"));
|
||||
VideoPlayerPlayground = React.lazy(() => import("./pages/VideoPlayerPlayground"));
|
||||
VideoFeedPlayground = React.lazy(() => import("./pages/VideoFeedPlayground"));
|
||||
VideoPlayerPlaygroundIntern = React.lazy(() => import("./pages/VideoPlayerPlaygroundIntern"));
|
||||
PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages"));
|
||||
PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor"));
|
||||
@ -149,7 +146,7 @@ const AppWrapper = () => {
|
||||
<Routes>
|
||||
{/* Top-level routes (no organization context) */}
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/auth" element={<AuthZ />} />
|
||||
<Route path="/authz" element={<AuthZ />} />
|
||||
<Route path="/auth/update-password" element={<React.Suspense fallback={<div>Loading...</div>}><UpdatePassword /></React.Suspense>} />
|
||||
<Route path="/profile/*" element={<Profile />} />
|
||||
@ -279,7 +276,10 @@ const App = () => {
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<OidcProvider
|
||||
userManager={userManager}
|
||||
onSigninCallback={() => window.history.replaceState({}, document.title, window.location.pathname)}
|
||||
onSigninCallback={(user) => {
|
||||
const redirectTo = (user?.state as any)?.redirectTo ?? '/';
|
||||
window.location.replace(redirectTo);
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import {
|
||||
Dialog,
|
||||
@ -57,50 +57,26 @@ const AddToCollectionModal = ({ isOpen, onClose, pictureId, postId }: AddToColle
|
||||
|
||||
const fetchCollections = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
try {
|
||||
const data = await apiClient<Collection[]>(`/api/collections?userId=${user.id}`);
|
||||
setCollections(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setCollections(data || []);
|
||||
};
|
||||
|
||||
const fetchItemCollections = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (postId) {
|
||||
// Fetch for post
|
||||
const { data, error } = await supabase
|
||||
.from('collection_posts' as any) // Cast as any until types are generated
|
||||
.select('collection_id')
|
||||
.eq('post_id', postId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching post collections:', error);
|
||||
return;
|
||||
try {
|
||||
if (postId) {
|
||||
const data = await apiClient<{ collection_id: string }[]>(`/api/collection-pictures?postId=${postId}`);
|
||||
setSelectedCollections(new Set(data.map((item: any) => item.collection_id)));
|
||||
} else if (pictureId) {
|
||||
const data = await apiClient<{ collection_id: string }[]>(`/api/collection-pictures?pictureId=${pictureId}`);
|
||||
setSelectedCollections(new Set(data.map(item => item.collection_id)));
|
||||
}
|
||||
const collectionIds = new Set(data.map((item: any) => item.collection_id));
|
||||
setSelectedCollections(collectionIds);
|
||||
} else if (pictureId) {
|
||||
// Fetch for picture
|
||||
const { data, error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select('collection_id')
|
||||
.eq('picture_id', pictureId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching picture collections:', error);
|
||||
return;
|
||||
}
|
||||
const collectionIds = new Set(data.map(item => item.collection_id));
|
||||
setSelectedCollections(collectionIds);
|
||||
} catch (error) {
|
||||
console.error('Error fetching item collections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -119,56 +95,35 @@ const AddToCollectionModal = ({ isOpen, onClose, pictureId, postId }: AddToColle
|
||||
setLoading(true);
|
||||
const slug = createSlug(newCollection.name);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: newCollection.name.trim(),
|
||||
description: newCollection.description.trim() || null,
|
||||
slug,
|
||||
is_public: newCollection.is_public
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to create collection",
|
||||
variant: "destructive"
|
||||
try {
|
||||
const data = await apiClient<Collection>('/api/collections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: user.id,
|
||||
name: newCollection.name.trim(),
|
||||
description: newCollection.description.trim() || null,
|
||||
slug,
|
||||
is_public: newCollection.is_public,
|
||||
}),
|
||||
});
|
||||
|
||||
if (postId) {
|
||||
await apiClient('/api/collection-pictures', { method: 'POST', body: JSON.stringify({ inserts: [{ collection_id: data.id, post_id: postId }] }) });
|
||||
} else if (pictureId) {
|
||||
await apiClient('/api/collection-pictures', { method: 'POST', body: JSON.stringify({ inserts: [{ collection_id: data.id, picture_id: pictureId }] }) });
|
||||
}
|
||||
|
||||
setCollections(prev => [data, ...prev]);
|
||||
setSelectedCollections(prev => new Set([...prev, data.id]));
|
||||
setNewCollection({ name: '', description: '', is_public: true });
|
||||
setShowCreateForm(false);
|
||||
toast({ title: "Success", description: "Collection created and photo added!" });
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
toast({ title: "Error", description: "Failed to create collection", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add item to new collection
|
||||
if (postId) {
|
||||
await supabase
|
||||
.from('collection_posts' as any)
|
||||
.insert({
|
||||
collection_id: data.id,
|
||||
post_id: postId
|
||||
});
|
||||
} else if (pictureId) {
|
||||
await supabase
|
||||
.from('collection_pictures')
|
||||
.insert({
|
||||
collection_id: data.id,
|
||||
picture_id: pictureId
|
||||
});
|
||||
}
|
||||
|
||||
setCollections(prev => [data, ...prev]);
|
||||
setSelectedCollections(prev => new Set([...prev, data.id]));
|
||||
setNewCollection({ name: '', description: '', is_public: true });
|
||||
setShowCreateForm(false);
|
||||
setLoading(false);
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Collection created and photo added!"
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleCollection = async (collectionId: string) => {
|
||||
@ -176,50 +131,26 @@ const AddToCollectionModal = ({ isOpen, onClose, pictureId, postId }: AddToColle
|
||||
|
||||
const isSelected = selectedCollections.has(collectionId);
|
||||
|
||||
if (isSelected) {
|
||||
// Remove from collection
|
||||
if (postId) {
|
||||
const { error } = await supabase
|
||||
.from('collection_posts' as any)
|
||||
.delete()
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('post_id', postId);
|
||||
if (error) console.error('Error removing post from collection:', error);
|
||||
} else if (pictureId) {
|
||||
const { error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.delete()
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('picture_id', pictureId);
|
||||
if (error) console.error('Error removing picture from collection:', error);
|
||||
try {
|
||||
if (isSelected) {
|
||||
const body = postId
|
||||
? { collection_id: collectionId, post_id: postId }
|
||||
: { collection_id: collectionId, picture_id: pictureId };
|
||||
await apiClient('/api/collection-pictures', { method: 'DELETE', body: JSON.stringify(body) });
|
||||
setSelectedCollections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(collectionId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
const insert = postId
|
||||
? { collection_id: collectionId, post_id: postId }
|
||||
: { collection_id: collectionId, picture_id: pictureId };
|
||||
await apiClient('/api/collection-pictures', { method: 'POST', body: JSON.stringify({ inserts: [insert] }) });
|
||||
setSelectedCollections(prev => new Set([...prev, collectionId]));
|
||||
}
|
||||
|
||||
setSelectedCollections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(collectionId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
// Add to collection
|
||||
if (postId) {
|
||||
const { error } = await supabase
|
||||
.from('collection_posts' as any)
|
||||
.insert({
|
||||
collection_id: collectionId,
|
||||
post_id: postId
|
||||
});
|
||||
if (error) console.error('Error adding post to collection:', error);
|
||||
} else if (pictureId) {
|
||||
const { error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.insert({
|
||||
collection_id: collectionId,
|
||||
picture_id: pictureId
|
||||
});
|
||||
if (error) console.error('Error adding picture to collection:', error);
|
||||
}
|
||||
|
||||
setSelectedCollections(prev => new Set([...prev, collectionId]));
|
||||
} catch (error) {
|
||||
console.error('Error toggling collection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ import { useMediaRefresh } from '@/contexts/MediaRefreshContext';
|
||||
import { createPicture } from '@/modules/posts/client-pictures';
|
||||
import { fetchPostById } from '@/modules/posts/client-posts';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { getAuthToken } from '@/lib/db';
|
||||
import { serverUrl } from '@/lib/db';
|
||||
|
||||
interface CreationWizardPopupProps {
|
||||
@ -293,8 +293,8 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: { session: authSession } } = await supabase.auth.getSession();
|
||||
if (!authSession?.access_token) {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
toast.error(translate('Please sign in to upload files'));
|
||||
return;
|
||||
}
|
||||
@ -309,7 +309,7 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
|
||||
const file = img.file as File;
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${serverUrl}/api/vfs/upload/${mount}/${encodeURIComponent(file.name)}`, true);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${authSession.access_token}`);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
|
||||
xhr.upload.onprogress = (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
@ -348,8 +348,7 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
|
||||
|
||||
// Helper function to upload internal video
|
||||
const uploadInternalVideo = async (file: File): Promise<void> => {
|
||||
// Get auth token before creating the XHR
|
||||
const { data: { session: authSession } } = await supabase.auth.getSession();
|
||||
const videoToken = await getAuthToken();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
|
||||
@ -360,8 +359,8 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${serverUrl}/api/videos/upload?userId=${user?.id}&title=${encodeURIComponent(title)}&preset=original`);
|
||||
if (authSession?.access_token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${authSession.access_token}`);
|
||||
if (videoToken) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${videoToken}`);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
|
||||
@ -8,7 +8,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { updatePicture } from '@/modules/posts/client-pictures';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
@ -90,13 +90,7 @@ const EditImageModal = ({
|
||||
|
||||
setLoadingCollections(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
const data = await apiClient<Collection[]>(`/api/collections?userId=${user.id}`);
|
||||
setCollections(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
@ -110,15 +104,8 @@ const EditImageModal = ({
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select('collection_id')
|
||||
.eq('picture_id', pictureId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const collectionIds = new Set(data.map(item => item.collection_id));
|
||||
setSelectedCollections(collectionIds);
|
||||
const data = await apiClient<{ collection_id: string }[]>(`/api/collection-pictures?pictureId=${pictureId}`);
|
||||
setSelectedCollections(new Set(data.map(item => item.collection_id)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching picture collections:', error);
|
||||
}
|
||||
@ -131,33 +118,21 @@ const EditImageModal = ({
|
||||
|
||||
try {
|
||||
if (isSelected) {
|
||||
// Remove from collection
|
||||
const { error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.delete()
|
||||
.eq('collection_id', collectionId)
|
||||
.eq('picture_id', pictureId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
await apiClient(`/api/collection-pictures`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ collection_id: collectionId, picture_id: pictureId }),
|
||||
});
|
||||
setSelectedCollections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(collectionId);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
toast.success(translate('Removed from collection'));
|
||||
} else {
|
||||
// Add to collection
|
||||
const { error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.insert({
|
||||
collection_id: collectionId,
|
||||
picture_id: pictureId
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
await apiClient(`/api/collection-pictures`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inserts: [{ collection_id: collectionId, picture_id: pictureId }] }),
|
||||
});
|
||||
setSelectedCollections(prev => new Set([...prev, collectionId]));
|
||||
toast.success(translate('Added to collection'));
|
||||
}
|
||||
|
||||
@ -10,7 +10,8 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { updatePicture } from '@/modules/posts/client-pictures';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import { Mic, MicOff, Loader2, Bookmark } from 'lucide-react';
|
||||
@ -89,13 +90,7 @@ const EditVideoModal = ({
|
||||
|
||||
setLoadingCollections(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
const data = await apiClient<Collection[]>(`/api/collections?userId=${user.id}`);
|
||||
setCollections(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
@ -109,18 +104,8 @@ const EditVideoModal = ({
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Reuse collection_pictures table with picture_id field for videos
|
||||
const { data, error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select('collection_id')
|
||||
.eq('picture_id', videoId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data) {
|
||||
const collectionIds = data.map(cp => cp.collection_id);
|
||||
setSelectedCollections(new Set(collectionIds));
|
||||
}
|
||||
const data = await apiClient<{ collection_id: string }[]>(`/api/collection-pictures?pictureId=${videoId}`);
|
||||
setSelectedCollections(new Set(data.map(cp => cp.collection_id)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching video collections:', error);
|
||||
}
|
||||
@ -141,44 +126,28 @@ const EditVideoModal = ({
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
// Update video metadata in pictures table
|
||||
const { error: updateError } = await supabase
|
||||
.from('pictures')
|
||||
.update({
|
||||
title: data.title?.trim() || null,
|
||||
description: data.description?.trim() || null,
|
||||
visible: data.visible,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', videoId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('type', 'mux-video');
|
||||
await updatePicture(videoId, {
|
||||
title: data.title?.trim() || null,
|
||||
description: data.description?.trim() || null,
|
||||
visible: data.visible,
|
||||
updated_at: new Date().toISOString(),
|
||||
} as any);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Update collections (reuse collection_pictures table with picture_id for videos)
|
||||
try {
|
||||
// First, remove all existing collection associations
|
||||
await supabase
|
||||
.from('collection_pictures')
|
||||
.delete()
|
||||
.eq('picture_id', videoId);
|
||||
|
||||
// Then add new associations
|
||||
await apiClient('/api/collection-pictures', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ picture_id: videoId, deleteAll: true }),
|
||||
});
|
||||
if (selectedCollections.size > 0) {
|
||||
const collectionInserts = Array.from(selectedCollections).map(collectionId => ({
|
||||
const inserts = Array.from(selectedCollections).map(collectionId => ({
|
||||
collection_id: collectionId,
|
||||
picture_id: videoId,
|
||||
}));
|
||||
|
||||
await supabase
|
||||
.from('collection_pictures')
|
||||
.insert(collectionInserts);
|
||||
await apiClient('/api/collection-pictures', { method: 'POST', body: JSON.stringify({ inserts }) });
|
||||
}
|
||||
} catch (collectionError) {
|
||||
console.error('Collection update failed:', collectionError);
|
||||
toast.error(translate('Failed to update collections'));
|
||||
// Don't fail the whole update if collections fail
|
||||
}
|
||||
|
||||
toast.success(translate('Video updated successfully'));
|
||||
|
||||
@ -4,7 +4,7 @@ import { set } from 'idb-keyval';
|
||||
import { toast } from 'sonner';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { getAuthToken } from '@/lib/db';
|
||||
import { useDragDrop } from '@/contexts/DragDropContext';
|
||||
|
||||
const GlobalDragDrop = () => {
|
||||
@ -52,11 +52,9 @@ const GlobalDragDrop = () => {
|
||||
toast.info(translate('Processing link...'));
|
||||
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = await getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (session?.access_token) {
|
||||
headers['Authorization'] = `Bearer ${session.access_token}`;
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/serving/site-info?url=${encodeURIComponent(url)}`, {
|
||||
|
||||
@ -12,26 +12,18 @@ import {
|
||||
addCollectionPictures,
|
||||
} from "@/modules/posts/client-pictures";
|
||||
import { uploadImage } from "@/lib/uploadUtils";
|
||||
import { getUserOpenAIKey } from "@/modules/user/client-user";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { getUserOpenAIKey, getUserSettings, updateUserSettings } from "@/modules/user/client-user";
|
||||
|
||||
// Re-export for backward compat
|
||||
export { getUserOpenAIKey };
|
||||
|
||||
/**
|
||||
* Load saved wizard model from user_secrets.settings.wizard_model
|
||||
* Load saved wizard model from user settings
|
||||
*/
|
||||
export const loadWizardModel = async (userId: string): Promise<string | null> => {
|
||||
try {
|
||||
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?.wizard_model || null;
|
||||
const settings = await getUserSettings(userId);
|
||||
return (settings as any)?.wizard_model || null;
|
||||
} catch (error) {
|
||||
console.error('Error loading wizard model:', error);
|
||||
return null;
|
||||
@ -39,28 +31,11 @@ export const loadWizardModel = async (userId: string): Promise<string | null> =>
|
||||
};
|
||||
|
||||
/**
|
||||
* Save selected model to user_secrets.settings.wizard_model
|
||||
* Save selected model to user settings
|
||||
*/
|
||||
export const saveWizardModel = async (userId: string, model: string): Promise<void> => {
|
||||
try {
|
||||
const { data: existing } = await supabase
|
||||
.from('user_secrets')
|
||||
.select('settings')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
const currentSettings = (existing.settings as Record<string, any>) || {};
|
||||
const newSettings = { ...currentSettings, wizard_model: model };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_secrets')
|
||||
.update({ settings: newSettings })
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
// If no user_secrets row exists, skip silently (will be created when user saves API keys)
|
||||
await updateUserSettings(userId, { wizard_model: model });
|
||||
} catch (error) {
|
||||
console.error('Error saving wizard model:', error);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Building2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
@ -7,15 +7,7 @@ import { Link } from "react-router-dom";
|
||||
const OrganizationsList = () => {
|
||||
const { data: organizations, isLoading } = useQuery({
|
||||
queryKey: ["organizations"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("organizations")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
queryFn: () => apiClient<any[]>('/api/organizations'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@ -52,7 +52,6 @@ interface MediaGridProps {
|
||||
onFilesDrop?: (files: File[]) => void;
|
||||
showVideos?: boolean; // Toggle video display (kept for backward compatibility)
|
||||
sortBy?: FeedSortOption;
|
||||
supabaseClient?: any;
|
||||
apiUrl?: string;
|
||||
categorySlugs?: string[];
|
||||
categoryIds?: string[];
|
||||
@ -74,7 +73,6 @@ const MediaGrid = ({
|
||||
onFilesDrop,
|
||||
showVideos = true,
|
||||
sortBy = 'latest',
|
||||
supabaseClient,
|
||||
apiUrl,
|
||||
categorySlugs,
|
||||
categoryIds,
|
||||
@ -117,7 +115,6 @@ const MediaGrid = ({
|
||||
visibilityFilter,
|
||||
// Disable hook if we have custom pictures
|
||||
enabled: !customPictures,
|
||||
supabaseClient
|
||||
});
|
||||
|
||||
// Derive loading from hook/props instead of syncing via setState
|
||||
|
||||
@ -6,7 +6,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Upload, RefreshCw, GitBranch, Bookmark } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface Collection {
|
||||
@ -60,13 +60,7 @@ export default function PublishDialog({
|
||||
|
||||
setLoadingCollections(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.select('id, name, slug')
|
||||
.eq('user_id', user.id)
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
const data = await apiClient<Collection[]>(`/api/collections?userId=${user.id}`);
|
||||
setCollections(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading collections:', error);
|
||||
|
||||
@ -21,6 +21,7 @@ const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m =>
|
||||
|
||||
const TopNavigation = () => {
|
||||
const { user, signOut, roles } = useAuth();
|
||||
console.log('user', user);
|
||||
const { fetchProfile, profiles } = useProfiles();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@ -44,7 +45,7 @@ const TopNavigation = () => {
|
||||
return () => unsubscribe?.();
|
||||
}, []);
|
||||
|
||||
const authPath = '/auth';
|
||||
const authPath = '/authz';
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
|
||||
@ -5,9 +5,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Tables, TablesInsert, TablesUpdate } from '@/integrations/supabase/types';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { DEFAULT_PROVIDERS, fetchProviderModelInfo } from '@/llm/filters/providers';
|
||||
import { groupModelsByCompany } from '@/llm/filters/providers/openrouter';
|
||||
@ -71,7 +69,19 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
type ProviderConfig = Tables<'provider_configs'>;
|
||||
type ProviderConfig = {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_url?: string | null;
|
||||
models?: string[] | any;
|
||||
rate_limits?: Record<string, any> | null;
|
||||
is_active?: boolean | null;
|
||||
settings?: Record<string, any> | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
interface ProviderSettings {
|
||||
apiKey?: string;
|
||||
@ -109,19 +119,12 @@ export const ProviderManagement: React.FC = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch both providers and user secrets in parallel
|
||||
const [providersParams, userSecrets] = await Promise.all([
|
||||
supabase
|
||||
.from('provider_configs')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('display_name', { ascending: true }),
|
||||
const [loadedProvidersRaw, userSecrets] = await Promise.all([
|
||||
apiClient<ProviderConfig[]>(`/api/provider-configs?userId=${user.id}`),
|
||||
getUserSecrets(user.id)
|
||||
]);
|
||||
|
||||
if (providersParams.error) throw providersParams.error;
|
||||
|
||||
let loadedProviders = providersParams.data || [];
|
||||
let loadedProviders = loadedProvidersRaw || [];
|
||||
|
||||
// Merge secrets into provider configurations for display
|
||||
if (userSecrets) {
|
||||
@ -167,12 +170,7 @@ export const ProviderManagement: React.FC = () => {
|
||||
if (!deletingProvider) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('provider_configs')
|
||||
.delete()
|
||||
.eq('id', deletingProvider.id);
|
||||
|
||||
if (error) throw error;
|
||||
await apiClient(`/api/provider-configs/${deletingProvider.id}`, { method: 'DELETE' });
|
||||
|
||||
toast.success(`Provider "${deletingProvider.display_name}" deleted successfully`);
|
||||
setDeletingProvider(null);
|
||||
@ -356,7 +354,7 @@ export const ProviderManagement: React.FC = () => {
|
||||
// Provider Edit Dialog Component
|
||||
interface ProviderEditDialogProps {
|
||||
provider: ProviderConfig | null;
|
||||
user: User;
|
||||
user: any;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: () => void;
|
||||
@ -569,34 +567,21 @@ const ProviderEditDialog: React.FC<ProviderEditDialogProps> = ({
|
||||
}
|
||||
|
||||
// Update existing provider
|
||||
const { error } = await supabase
|
||||
.from('provider_configs')
|
||||
.update(data)
|
||||
.eq('id', provider.id);
|
||||
|
||||
if (error) throw error;
|
||||
await apiClient(`/api/provider-configs/${provider.id}`, { method: 'PATCH', body: JSON.stringify(data) });
|
||||
toast.success('Provider updated successfully');
|
||||
} else {
|
||||
// Create new provider with user_id
|
||||
|
||||
// Handle secrets for new provider too
|
||||
if (formData.name === 'openai' || formData.name === 'google') {
|
||||
try {
|
||||
const secretUpdate: Record<string, string> = {};
|
||||
if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || '';
|
||||
if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || '';
|
||||
|
||||
await updateUserSecrets(user.id, secretUpdate);
|
||||
} catch (secretError) {
|
||||
console.error('Failed to update user secrets:', secretError);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('provider_configs')
|
||||
.insert([{ ...data, user_id: user.id }]);
|
||||
|
||||
if (error) throw error;
|
||||
await apiClient('/api/provider-configs', { method: 'POST', body: JSON.stringify({ ...data, user_id: user.id }) });
|
||||
toast.success('Provider created successfully');
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ProviderConfig } from '@/llm/filters/types';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -69,18 +69,8 @@ export const ProviderSelector: React.FC<ProviderSelectorProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: userProviders, error } = await supabase
|
||||
.from('provider_configs')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('display_name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to load user providers:', error);
|
||||
setProviders([]);
|
||||
} else if (userProviders) {
|
||||
// Convert database providers to ProviderConfig
|
||||
const userProviders = await apiClient<any[]>(`/api/provider-configs?userId=${user.id}&is_active=true`);
|
||||
if (userProviders) {
|
||||
const providers = userProviders.map(dbProvider => ({
|
||||
name: dbProvider.name,
|
||||
displayName: dbProvider.display_name,
|
||||
|
||||
@ -7,7 +7,7 @@ import { AIPromptPopup } from './AIPromptPopup';
|
||||
import { generateText } from '@/lib/openai';
|
||||
import { toast } from 'sonner';
|
||||
import { getUserApiKeys as getUserSecrets } from '@/modules/user/client-user';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { formatTextGenPrompt } from '@/constants';
|
||||
|
||||
@ -33,15 +33,9 @@ const useProviderApiKey = () => {
|
||||
|
||||
try {
|
||||
console.log('Fetching API key for user:', user.id, 'provider:', provider);
|
||||
const { data: userProvider, error } = await supabase
|
||||
.from('provider_configs')
|
||||
.select('settings')
|
||||
.eq('user_id', user.id)
|
||||
.eq('name', provider)
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
|
||||
if (error || !userProvider) return null;
|
||||
const configs = await apiClient<any[]>(`/api/provider-configs?userId=${user.id}&name=${provider}&is_active=true`);
|
||||
const userProvider = configs?.[0];
|
||||
if (!userProvider) return null;
|
||||
const settings = userProvider.settings as any;
|
||||
return settings?.apiKey || null;
|
||||
} catch (error) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client'; // Still needed for collections (no API yet)
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { fetchPictures as fetchPicturesAPI, fetchUserPictures } from '@/modules/posts/client-pictures';
|
||||
import { fetchPostsList } from '@/modules/posts/client-posts';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
@ -349,13 +349,7 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.select('id, name, slug')
|
||||
.eq('user_id', user.id)
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
const data = await apiClient<any[]>(`/api/collections?userId=${user.id}`);
|
||||
setCollections(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching collections:', error);
|
||||
@ -390,13 +384,8 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select('picture_id')
|
||||
.in('collection_id', selectedCollections);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const params = selectedCollections.map(id => `collectionId=${id}`).join('&');
|
||||
const data = await apiClient<{ picture_id: string }[]>(`/api/collection-pictures?${params}`);
|
||||
const pictureIdsInCollections = new Set(data?.map(cp => cp.picture_id) || []);
|
||||
setFinalPictures(filteredPictures.filter(pic => pictureIdsInCollections.has(pic.id)));
|
||||
} catch (error) {
|
||||
@ -434,13 +423,8 @@ export const ImagePickerDialog: React.FC<ImagePickerDialogProps> = ({
|
||||
onMultiSelectPictures(allPics);
|
||||
}
|
||||
} else {
|
||||
console.log('Selected ID:', selectedId);
|
||||
console.log('Selected Post ID:', selectedPostId);
|
||||
|
||||
if (selectedId) {
|
||||
|
||||
if (onSelect) onSelect(selectedId);
|
||||
|
||||
if (onSelectPicture) {
|
||||
let pic = pictures.find(p => p.id === selectedId);
|
||||
// If not found in pictures (e.g. selected via posts mode), try the post's cover
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useAuth as useOidcAuth } from 'react-oidc-context';
|
||||
import { fetchUserRoles } from '@/modules/user/client-user';
|
||||
import { fetchUserIdentity } from '@/modules/user/client-user';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AuthUser {
|
||||
id: string; // Zitadel numeric sub
|
||||
/** Resolved app UUID (profiles.user_id) — use this for all API calls and routing */
|
||||
id: string;
|
||||
/** Original Zitadel numeric sub from the OIDC token */
|
||||
sub: string;
|
||||
email: string | undefined;
|
||||
user_metadata: {
|
||||
display_name?: string;
|
||||
@ -23,7 +26,7 @@ interface AuthContextType {
|
||||
roles: string[];
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
signIn: () => Promise<void>;
|
||||
signIn: (redirectTo?: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
/** @deprecated Use signIn() — redirects to Zitadel */
|
||||
signUp: (...args: any[]) => Promise<{ data: null; error: Error }>;
|
||||
@ -42,31 +45,41 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const oidcAuth = useOidcAuth();
|
||||
const [roles, setRoles] = useState<string[]>([]);
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
const [identityLoading, setIdentityLoading] = useState(false);
|
||||
// Resolved app UUID — starts as null until the /api/me/identity call completes
|
||||
const [resolvedId, setResolvedId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (oidcAuth.isLoading) return;
|
||||
|
||||
if (!oidcAuth.isAuthenticated || !oidcAuth.user) {
|
||||
setRoles([]);
|
||||
setResolvedId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = oidcAuth.user.profile.sub;
|
||||
|
||||
setRolesLoading(true);
|
||||
fetchUserRoles(sub)
|
||||
.then(r => setRoles(r as string[]))
|
||||
.catch(err => {
|
||||
console.error('[AuthProvider] Failed to fetch roles:', err);
|
||||
setRoles([]);
|
||||
setIdentityLoading(true);
|
||||
fetchUserIdentity()
|
||||
.then(({ id, roles: r }) => {
|
||||
setResolvedId(id);
|
||||
setRoles(r);
|
||||
})
|
||||
.finally(() => setRolesLoading(false));
|
||||
.catch(err => {
|
||||
console.error('[AuthProvider] Failed to fetch identity:', err);
|
||||
setRoles([]);
|
||||
// Fall back to the raw numeric sub so the app isn't completely broken
|
||||
setResolvedId(null);
|
||||
})
|
||||
.finally(() => setIdentityLoading(false));
|
||||
}, [oidcAuth.isAuthenticated, oidcAuth.isLoading]);
|
||||
|
||||
const oidcSub = oidcAuth.user?.profile.sub ?? '';
|
||||
|
||||
const user: AuthUser | null = oidcAuth.user
|
||||
? {
|
||||
id: oidcAuth.user.profile.sub,
|
||||
// Use the server-resolved UUID; fall back to the OIDC sub while loading
|
||||
id: resolvedId ?? oidcSub,
|
||||
sub: oidcSub,
|
||||
email: oidcAuth.user.profile.email,
|
||||
user_metadata: {
|
||||
display_name: oidcAuth.user.profile.name,
|
||||
@ -84,22 +97,16 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
user,
|
||||
session: null,
|
||||
roles,
|
||||
loading: oidcAuth.isLoading || rolesLoading,
|
||||
loading: oidcAuth.isLoading || identityLoading,
|
||||
isAuthenticated: oidcAuth.isAuthenticated,
|
||||
signIn: () => oidcAuth.signinRedirect(),
|
||||
signOut: () => oidcAuth.signoutRedirect(),
|
||||
signIn: (redirectTo = '/') => oidcAuth.signinRedirect({ state: { redirectTo } }),
|
||||
signOut: () => oidcAuth.signoutRedirect({ post_logout_redirect_uri: window.location.origin + '/' }),
|
||||
signUp: async () => ({
|
||||
data: null,
|
||||
error: new Error('Sign up via Zitadel — use the /authz page'),
|
||||
}),
|
||||
signInWithGithub: async () => {
|
||||
await oidcAuth.signinRedirect();
|
||||
return { error: null };
|
||||
},
|
||||
signInWithGoogle: async () => {
|
||||
await oidcAuth.signinRedirect();
|
||||
return { error: null };
|
||||
},
|
||||
signInWithGithub: async () => { await oidcAuth.signinRedirect(); return { error: null }; },
|
||||
signInWithGoogle: async () => { await oidcAuth.signinRedirect(); return { error: null }; },
|
||||
resetPassword: async () => ({
|
||||
error: new Error('Reset password via the Zitadel portal'),
|
||||
}),
|
||||
|
||||
@ -8,8 +8,6 @@ import { getCurrentLang } from '@/i18n';
|
||||
import { fetchWithDeduplication, getAuthToken } from '@/lib/db';
|
||||
import { fetchFeed } from '@/modules/feed/client-feed';
|
||||
|
||||
const { supabase } = await import('@/integrations/supabase/client');
|
||||
|
||||
export type FeedSortOption = 'latest' | 'top';
|
||||
|
||||
interface UseFeedDataProps {
|
||||
@ -19,7 +17,6 @@ interface UseFeedDataProps {
|
||||
orgSlug?: string;
|
||||
enabled?: boolean;
|
||||
sortBy?: FeedSortOption;
|
||||
supabaseClient?: any;
|
||||
categoryIds?: string[];
|
||||
categorySlugs?: string[];
|
||||
contentType?: 'posts' | 'pages' | 'pictures' | 'files' | 'places';
|
||||
@ -33,7 +30,6 @@ export const useFeedData = ({
|
||||
orgSlug,
|
||||
enabled = true,
|
||||
sortBy = 'latest',
|
||||
supabaseClient,
|
||||
categoryIds,
|
||||
categorySlugs,
|
||||
contentType,
|
||||
@ -232,7 +228,7 @@ export const useFeedData = ({
|
||||
setLoading(false);
|
||||
setIsFetchingMore(false);
|
||||
}
|
||||
}, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, supabaseClient, categoryIds, categorySlugs, contentType, visibilityFilter]);
|
||||
}, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, categoryIds, categorySlugs, contentType, visibilityFilter]);
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
|
||||
@ -4,7 +4,7 @@ import { toast } from 'sonner';
|
||||
import { useWidgetLoader } from './useWidgetLoader.tsx';
|
||||
import { useLayouts } from '@/modules/layout/useLayouts';
|
||||
import { Database } from '@/integrations/supabase/types';
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
|
||||
type Layout = Database['public']['Tables']['layouts']['Row'];
|
||||
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
|
||||
@ -420,30 +420,14 @@ export function usePlaygroundLogic() {
|
||||
|
||||
toast.info("Sending test email...");
|
||||
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const token = sessionData.session?.access_token;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/send/email/${dummyId}`, {
|
||||
await apiClient(`/api/send/email/${dummyId}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
html,
|
||||
subject: `[Test] ${layout.name} - ${new Date().toLocaleTimeString()}`
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Test email sent!");
|
||||
} else {
|
||||
const err = await response.text();
|
||||
console.error("Failed to send test email", err);
|
||||
toast.error(`Failed to send: ${response.statusText}`);
|
||||
}
|
||||
toast.success("Test email sent!");
|
||||
|
||||
} catch (e) {
|
||||
console.error("Failed to send test email", e);
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Tables } from '@/integrations/supabase/types';
|
||||
import { apiClient } from '@/lib/db';
|
||||
|
||||
export type ProviderConfig = Tables<'provider_configs'>;
|
||||
export type ProviderConfig = {
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_url?: string;
|
||||
models?: string[];
|
||||
rate_limits?: Record<string, any>;
|
||||
is_active?: boolean;
|
||||
settings?: Record<string, any>;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'provider-settings';
|
||||
|
||||
@ -34,14 +42,7 @@ export const useProviderSettings = () => {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('provider_configs')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('display_name');
|
||||
|
||||
if (error) throw error;
|
||||
const data = await apiClient<ProviderConfig[]>(`/api/provider-configs?userId=${user.id}&is_active=true`);
|
||||
|
||||
setProviders(data || []);
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { GoogleGenerativeAI, Part, GenerationConfig } from "@google/generative-ai";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { userManager } from "@/lib/oidc";
|
||||
import { getUserGoogleApiKey } from "./modules/user/client-user";
|
||||
|
||||
// Simple logger for user feedback (safety messages)
|
||||
@ -39,13 +39,16 @@ interface ImageResult {
|
||||
// Get user's Google API key from user_secrets
|
||||
export const getGoogleApiKey = async (): Promise<string | null> => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
const oidcUser = await userManager.getUser();
|
||||
const userId = oidcUser?.profile.sub
|
||||
? (process.env.VITE_ZITADEL_ADMIN_UUID || oidcUser.profile.sub)
|
||||
: null;
|
||||
if (!userId) {
|
||||
logger.error('No authenticated user found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKey = await getUserGoogleApiKey(user.id);
|
||||
const apiKey = await getUserGoogleApiKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
logger.error('No Google API key found in user secrets. Please add your Google API key in your profile settings.');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
|
||||
// Simple logger for user feedback
|
||||
const logger = {
|
||||
@ -10,32 +10,16 @@ const logger = {
|
||||
|
||||
const AIMLAPI_BASE_URL = 'https://api.aimlapi.com';
|
||||
|
||||
// Get user's AIML API key from their profile
|
||||
// Get user's AIML API key from server secrets
|
||||
const getAimlApiKey = async (): Promise<string | null> => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
logger.error('No authenticated user found');
|
||||
const data = await apiClient<{ api_keys?: Record<string, any> }>('/api/me/secrets');
|
||||
const key = data.api_keys?.aimlapi_api_key;
|
||||
if (!key) {
|
||||
logger.error('No AIML API key found. Please add your AIML API key in your profile settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: profile, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('aimlapi_api_key')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!profile?.aimlapi_api_key) {
|
||||
logger.error('No AIML API key found in user profile. Please add your AIML API key in your profile settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return profile.aimlapi_api_key;
|
||||
return key;
|
||||
} catch (error) {
|
||||
logger.error('Error getting AIML API key:', error);
|
||||
return null;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
|
||||
// Simple logger for user feedback
|
||||
const logger = {
|
||||
@ -10,32 +10,16 @@ const logger = {
|
||||
|
||||
const BRIA_BASE_URL = 'https://engine.prod.bria-api.com/v1';
|
||||
|
||||
// Get user's Bria API key from their profile
|
||||
// Get user's Bria API key from server secrets
|
||||
const getBriaApiKey = async (): Promise<string | null> => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
logger.error('No authenticated user found');
|
||||
const data = await apiClient<{ api_keys?: Record<string, any> }>('/api/me/secrets');
|
||||
const key = data.api_keys?.bria_api_key;
|
||||
if (!key) {
|
||||
logger.error('No Bria API key found. Please add your Bria API key in your profile settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: profile, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('bria_api_key')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!profile?.bria_api_key) {
|
||||
logger.error('No Bria API key found in user profile. Please add your Bria API key in your profile settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return profile.bria_api_key;
|
||||
return key;
|
||||
} catch (error) {
|
||||
logger.error('Error getting Bria API key:', error);
|
||||
return null;
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { queryClient } from './queryClient';
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
import { userManager } from './oidc';
|
||||
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
// Deprecated: Caching now handled by React Query
|
||||
// Keeping for backward compatibility
|
||||
type CacheStorageType = 'memory' | 'local';
|
||||
@ -149,17 +146,13 @@ export const invalidateServerCache = async (types: string[]) => {
|
||||
|
||||
|
||||
|
||||
export const checkLikeStatus = async (userId: string, pictureId: string, client?: SupabaseClient) => {
|
||||
const supabase = client || defaultSupabase;
|
||||
export const checkLikeStatus = async (userId: string, pictureId: string) => {
|
||||
return fetchWithDeduplication(`like-${userId}-${pictureId}`, async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('likes')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('picture_id', pictureId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return !!data;
|
||||
try {
|
||||
const data = await apiClient<{ liked: boolean }>(`/api/pictures/${pictureId}/like-status?userId=${userId}`);
|
||||
return data.liked;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ export const userManager = new UserManager({
|
||||
authority,
|
||||
client_id,
|
||||
redirect_uri: window.location.origin + '/authz',
|
||||
post_logout_redirect_uri: window.location.origin + '/authz',
|
||||
post_logout_redirect_uri: window.location.origin + '/',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
loadUserInfo: true,
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* See PRESET_TOOLS mapping below for tool combinations.
|
||||
*/
|
||||
import OpenAI from 'openai';
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { getAuthToken as getZitadelToken } from "@/lib/db";
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { RunnableToolFunctionWithParse } from 'openai/lib/RunnableFunction';
|
||||
@ -80,12 +80,12 @@ const PRESET_TOOLS: Record<PresetType, (apiKey?: string) => RunnableToolFunction
|
||||
// Get user's session token for proxy authentication
|
||||
const getAuthToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
const token = await getZitadelToken();
|
||||
if (!token) {
|
||||
consoleLogger.error('No authenticated session found');
|
||||
return null;
|
||||
}
|
||||
return session.access_token;
|
||||
return token;
|
||||
} catch (error) {
|
||||
consoleLogger.error('Error getting auth token:', error);
|
||||
return null;
|
||||
@ -1281,8 +1281,7 @@ export const runTools = async (options: RunToolsOptions): Promise<RunToolsResult
|
||||
// OpenRouter models (e.g. 'google/gemini-2.5-pro') need the OpenRouter proxy
|
||||
let token: string | undefined = undefined;
|
||||
try {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
token = data?.session?.access_token;
|
||||
token = await getZitadelToken() ?? undefined;
|
||||
} catch { }
|
||||
if (!token) {
|
||||
return {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { getAuthToken, serverUrl } from "@/lib/db";
|
||||
|
||||
// Simple logger for user feedback
|
||||
const logger = {
|
||||
@ -8,26 +8,18 @@ const logger = {
|
||||
error: (message: string, data?: any) => console.error(`[ERROR] ${message}`, data),
|
||||
};
|
||||
|
||||
// Call the edge function to proxy Replicate API requests
|
||||
// Call the server-side Replicate proxy
|
||||
const callReplicateProxy = async (model: string, input: any): Promise<any> => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
const SUPABASE_URL = "https://ytoadlpbdguriiccjnip.supabase.co";
|
||||
|
||||
const response = await fetch(`${SUPABASE_URL}/functions/v1/replicate-proxy`, {
|
||||
const response = await fetch(`${serverUrl}/api/replicate/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'run',
|
||||
model,
|
||||
input,
|
||||
}),
|
||||
body: JSON.stringify({ model, input }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { getAuthToken, serverUrl } from './db';
|
||||
import { getAuthToken, serverUrl, apiClient } from './db';
|
||||
|
||||
/**
|
||||
* Uploads an image file via the server API.
|
||||
@ -57,24 +56,20 @@ export const uploadGeneratedImageData = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a picture record in the database.
|
||||
* Creates a picture record in the database via API.
|
||||
*/
|
||||
export const createPictureRecord = async (userId: string, file: File, publicUrl: string, meta?: any) => {
|
||||
const { data, error } = await supabase
|
||||
.from('pictures')
|
||||
.insert({
|
||||
return apiClient('/api/pictures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
title: file.name.split('.')[0] || 'Uploaded Image',
|
||||
description: null,
|
||||
image_url: publicUrl,
|
||||
type: 'supabase-image',
|
||||
type: 'image',
|
||||
meta: meta || {},
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -5,8 +5,7 @@
|
||||
|
||||
import { ProviderConfig } from './types';
|
||||
import { generateText } from '@/lib/openai';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Tables } from '@/integrations/supabase/types';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { fetchOpenRouterModelInfo } from './providers/openrouter';
|
||||
import { fetchOpenAIModelInfo } from './providers/openai';
|
||||
|
||||
@ -66,26 +65,18 @@ export const getProviderConfig = (providerName: string): ProviderConfig | null =
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available providers from database or defaults
|
||||
* Get all available providers from API or defaults
|
||||
*/
|
||||
export const getAvailableProviders = async (): Promise<ProviderConfig[]> => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('provider_configs')
|
||||
.select('*')
|
||||
.eq('is_active', true);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const data = await apiClient<any[]>('/api/provider-configs?is_active=true');
|
||||
if (data && data.length > 0) {
|
||||
// Convert database format to ProviderConfig format
|
||||
return data.map(dbProvider => convertDbToConfig(dbProvider));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load providers from database, using defaults:', error);
|
||||
console.error('Failed to load providers from API, using defaults:', error);
|
||||
}
|
||||
|
||||
// Fallback to defaults
|
||||
return Object.values(DEFAULT_PROVIDERS).filter(provider => provider.isActive);
|
||||
};
|
||||
|
||||
@ -100,7 +91,7 @@ export const getAvailableProvidersSync = (): ProviderConfig[] => {
|
||||
/**
|
||||
* Convert database provider format to ProviderConfig
|
||||
*/
|
||||
const convertDbToConfig = (dbProvider: Tables<'provider_configs'>): ProviderConfig => {
|
||||
const convertDbToConfig = (dbProvider: any): ProviderConfig => {
|
||||
return {
|
||||
name: dbProvider.name,
|
||||
displayName: dbProvider.display_name,
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
|
||||
// Types based on ref/openai.ts
|
||||
interface OpenAIModel {
|
||||
@ -46,32 +46,13 @@ const getOpenAIApiKey = async (providedKey?: string): Promise<string | null> =>
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
console.error('No authenticated user found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: secretData, error } = await supabase
|
||||
.from('user_secrets')
|
||||
.select('settings')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching user secrets:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = secretData?.settings as { api_keys?: Record<string, string> } | null;
|
||||
const apiKey = settings?.api_keys?.openai_api_key;
|
||||
|
||||
if (!apiKey) {
|
||||
const data = await apiClient<{ api_keys?: Record<string, any> }>('/api/me/secrets');
|
||||
const key = data.api_keys?.openai_api_key;
|
||||
if (!key) {
|
||||
console.error('No OpenAI API key found in user secrets. Please add your OpenAI API key in your profile settings.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
return key;
|
||||
} catch (error) {
|
||||
console.error('Error getting OpenAI API key:', error);
|
||||
return null;
|
||||
|
||||
@ -33,7 +33,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
import { fetchAnalytics, clearAnalytics, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import {
|
||||
filterModelToParams,
|
||||
paramsToFilterModel,
|
||||
@ -372,23 +372,12 @@ const AnalyticsDashboard = () => {
|
||||
if (!confirm(`Ban ${ips.length} IP(s)? They will be blocked from all requests.`)) return;
|
||||
setBanning(true);
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const baseUrl = serverUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
|
||||
const res = await fetch(`${baseUrl}/api/admin/bans/ban-ip`, {
|
||||
const result = await apiClient<{ banned: number; message: string }>('/api/admin/bans/ban-ip', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${session?.access_token || ''}`
|
||||
},
|
||||
body: JSON.stringify({ ips })
|
||||
body: JSON.stringify({ ips }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(`${result.banned} IP(s) banned`, { description: result.message });
|
||||
setSelectedUserIps(EMPTY_SELECTION);
|
||||
} else {
|
||||
toast.error('Ban failed', { description: result.message || res.statusText });
|
||||
}
|
||||
toast.success(`${result.banned} IP(s) banned`, { description: result.message });
|
||||
setSelectedUserIps(EMPTY_SELECTION);
|
||||
} catch (err: any) {
|
||||
toast.error('Ban failed', { description: err.message });
|
||||
} finally {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Campaign {
|
||||
|
||||
@ -31,8 +31,7 @@ export const fetchCategories = async (options?: { parentSlug?: string; includeCh
|
||||
const cacheKey = parts.join('-');
|
||||
|
||||
return fetchWithDeduplication(cacheKey, async () => {
|
||||
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||
const token = sessionData.session?.access_token;
|
||||
const token = await getAuthToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
import { fetchWithDeduplication } from "@/lib/db";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
* Calls server routes at /api/contacts/mailboxes
|
||||
*/
|
||||
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
|
||||
const SERVER_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
|
||||
const API_BASE = '/api/contacts/mailboxes';
|
||||
|
||||
|
||||
@ -49,8 +49,7 @@ export const translateText = async (text: string, srcLang: string, dstLang: stri
|
||||
export const fetchGlossaries = async () => {
|
||||
// GET /api/i18n/glossaries
|
||||
return fetchWithDeduplication('i18n-glossaries', async () => {
|
||||
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||
const token = sessionData.session?.access_token;
|
||||
const token = await getAuthToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import FlexibleContainerRenderer from './FlexibleContainerRenderer';
|
||||
import { WidgetPalette } from './WidgetPalette';
|
||||
|
||||
import { uploadAndCreatePicture } from '@/lib/uploadUtils';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import { translate } from '@/i18n';
|
||||
|
||||
@ -52,6 +52,7 @@ const GenericCanvasEdit: React.FC<GenericCanvasEditProps> = ({
|
||||
onSave,
|
||||
selectionBreadcrumb
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
loadedPages,
|
||||
loadPageLayout,
|
||||
@ -165,7 +166,6 @@ const GenericCanvasEdit: React.FC<GenericCanvasEditProps> = ({
|
||||
const toastId = toast.loading(translate('Uploading images...'));
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
toast.error(translate('You must be logged in to upload images'), { id: toastId });
|
||||
return;
|
||||
|
||||
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { T, translate } from "@/i18n";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { mergePageVariables } from "@/lib/page-variables";
|
||||
import { useAppStore } from "@/store/appStore";
|
||||
|
||||
@ -185,16 +186,10 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
|
||||
// Fetch original (no lang) for the editor to avoid saving translations as source.
|
||||
const lang = getCurrentLang();
|
||||
if (lang && lang !== srcLang) {
|
||||
const { supabase: defaultSupabase } = await import('@/integrations/supabase/client');
|
||||
const { data: sessionData } = await defaultSupabase.auth.getSession();
|
||||
const token = sessionData.session?.access_token;
|
||||
const headers: HeadersInit = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`/api/user-page/${id}/${slugStr}`, { headers });
|
||||
if (res.ok) {
|
||||
const orig = await res.json();
|
||||
try {
|
||||
const orig = await apiClient<any>(`/api/user-page/${id}/${slugStr}`);
|
||||
setOriginalPage(orig.page);
|
||||
} else {
|
||||
} catch {
|
||||
setOriginalPage(data.page); // fallback
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -3,7 +3,7 @@ import { useDraggable } from '@dnd-kit/core';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { getAuthToken } from "@/lib/db";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutTemplate,
|
||||
@ -452,8 +452,7 @@ export const PageRibbonBar = ({
|
||||
const fetchSize = async () => {
|
||||
setLoadingSize(true);
|
||||
try {
|
||||
const session = await supabase.auth.getSession();
|
||||
const token = session.data.session?.access_token;
|
||||
const token = await getAuthToken();
|
||||
// Note: This endpoint must match the iframe preview endpoint
|
||||
const endpoint = `${baseUrl}/user/${page.owner}/pages/${page.slug}/email-preview`;
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { getPlacesGridSearchStreamUrl } from '../client-gridsearch';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { getAuthToken } from '@/lib/db';
|
||||
import { type LogEntry } from '@/contexts/LogContext';
|
||||
|
||||
export type StreamPhase = 'idle' | 'grid' | 'searching' | 'enriching' | 'complete' | 'failed';
|
||||
@ -317,8 +317,8 @@ export function GridSearchStreamProvider({
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const { data: session } = await supabase.auth.getSession();
|
||||
const url = getPlacesGridSearchStreamUrl(jobId, session.session?.access_token);
|
||||
const token = await getAuthToken();
|
||||
const url = getPlacesGridSearchStreamUrl(jobId, token);
|
||||
|
||||
es = new EventSource(url);
|
||||
setState(prev => ({ ...prev, streaming: true, connected: true }));
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { translate } from '@/i18n';
|
||||
import { getUserSettings, updateUserSettings } from '@/modules/user/client-user';
|
||||
|
||||
export interface PlacesSettings {
|
||||
known_types: string[];
|
||||
@ -19,15 +19,7 @@ export const usePlacesSettings = () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('settings')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const fetchedSettings = (data?.settings as Record<string, any>) || {};
|
||||
const fetchedSettings = (await getUserSettings(userId) as Record<string, any>) || {};
|
||||
setSettings({
|
||||
known_types: Array.isArray(fetchedSettings.known_types) ? fetchedSettings.known_types : [],
|
||||
excluded_types: Array.isArray(fetchedSettings.excluded_types) ? fetchedSettings.excluded_types : [],
|
||||
@ -43,42 +35,13 @@ export const usePlacesSettings = () => {
|
||||
const updateExcludedTypes = async (newExcludedTypes: string[]) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
// Optimistic update
|
||||
setSettings(prev => ({ ...prev, excluded_types: newExcludedTypes }));
|
||||
|
||||
// Re-fetch to ensure we have latest known_types before merging
|
||||
const { data: currentProfile, error: fetchError } = await supabase
|
||||
.from('profiles')
|
||||
.select('settings')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Error fetching settings for update:', fetchError);
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
// Preservation check: If we found no profile, but we expect one (e.g. existing user), this is risky.
|
||||
// However, maybeSingle returns null for new users.
|
||||
|
||||
const currentSettings = (currentProfile?.settings as Record<string, any>) || {};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
settings: {
|
||||
...currentSettings,
|
||||
excluded_types: newExcludedTypes,
|
||||
},
|
||||
}, { onConflict: 'user_id' });
|
||||
|
||||
if (error) throw error;
|
||||
const currentSettings = (await getUserSettings(user.id) as Record<string, any>) || {};
|
||||
await updateUserSettings(user.id, { ...currentSettings, excluded_types: newExcludedTypes });
|
||||
toast.success(translate('Settings saved'));
|
||||
} catch (error: any) {
|
||||
console.error('Error updating settings:', error);
|
||||
toast.error(translate('Failed to save settings'));
|
||||
// Revert on error
|
||||
fetchSettings();
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { getAuthToken } from "@/lib/db";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useNavigate, Navigate } from "react-router-dom";
|
||||
import { CreationWizardPopup } from "@/components/CreationWizardPopup";
|
||||
@ -31,11 +31,9 @@ const NewPost = () => {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
|
||||
addLog(`Fetching site info from: ${serverUrl || '/api'}/serving/site-info`);
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = await getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (session?.access_token) {
|
||||
headers['Authorization'] = `Bearer ${session.access_token}`;
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// Ensure we have a valid URL protocol
|
||||
let targetUrl = url;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { PostMediaItem } from "@/modules/posts/views/types";
|
||||
import { MediaItem } from "@/types";
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { fetchWithDeduplication, apiClient, getAuthHeaders } from "@/lib/db";
|
||||
import { uploadImage } from "@/lib/uploadUtils";
|
||||
import { FetchMediaOptions } from "@/utils/mediaUtils";
|
||||
@ -26,32 +25,32 @@ import { FetchMediaOptions } from "@/utils/mediaUtils";
|
||||
"widgetId": "photo-card"
|
||||
}
|
||||
*/
|
||||
export const fetchVersions = async (mediaItem: PostMediaItem, userId?: string, client?: SupabaseClient) => {
|
||||
export const fetchVersions = async (mediaItem: PostMediaItem, userId?: string) => {
|
||||
const key = `versions-${mediaItem.id}-${userId || 'anon'}`;
|
||||
return fetchWithDeduplication(key, async () => {
|
||||
const rootId = mediaItem.id; // Server resolves full parent chain via collectVersionTree
|
||||
const params = new URLSearchParams({ rootId, types: 'null,supabase-image' });
|
||||
const params = new URLSearchParams({ rootId });
|
||||
if (userId) params.append('userId', userId);
|
||||
if (mediaItem.user_id) params.append('ownerId', mediaItem.user_id);
|
||||
|
||||
return apiClient<any[]>(`/api/pictures/versions?${params}`);
|
||||
});
|
||||
};
|
||||
export const createPicture = async (picture: Partial<PostMediaItem>, client?: SupabaseClient) => {
|
||||
export const createPicture = async (picture: Partial<PostMediaItem>) => {
|
||||
return apiClient('/api/pictures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(picture)
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePicture = async (id: string, updates: Partial<PostMediaItem>, client?: SupabaseClient) => {
|
||||
export const updatePicture = async (id: string, updates: Partial<PostMediaItem>) => {
|
||||
return apiClient(`/api/pictures/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
};
|
||||
|
||||
export const deletePicture = async (id: string, client?: SupabaseClient) => {
|
||||
export const deletePicture = async (id: string) => {
|
||||
return apiClient(`/api/pictures/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
@ -79,7 +78,7 @@ export const fetchUserPictures = async (userId: string) => (await fetchPictures(
|
||||
/** Convenience alias: fetch N recent pictures */
|
||||
export const fetchRecentPictures = async (limit: number = 50) => (await fetchPictures({ limit })).data;
|
||||
|
||||
export const uploadFileToStorage = async (userId: string, file: File | Blob, fileName?: string, client?: SupabaseClient) => {
|
||||
export const uploadFileToStorage = async (userId: string, file: File | Blob, fileName?: string) => {
|
||||
const uploadFile = file instanceof File
|
||||
? file
|
||||
: new File([file], fileName || `${userId}/${Date.now()}-${Math.random().toString(36).substring(7)}`, {
|
||||
@ -89,25 +88,12 @@ export const uploadFileToStorage = async (userId: string, file: File | Blob, fil
|
||||
return publicUrl;
|
||||
};
|
||||
|
||||
export const toggleLike = async (userId: string, pictureId: string, isLiked: boolean, client?: SupabaseClient) => {
|
||||
const { supabase } = await import("@/integrations/supabase/client");
|
||||
const db = client || supabase;
|
||||
|
||||
if (isLiked) {
|
||||
const { error } = await db
|
||||
.from('likes')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('picture_id', pictureId);
|
||||
if (error) throw error;
|
||||
return false;
|
||||
} else {
|
||||
const { error } = await db
|
||||
.from('likes')
|
||||
.insert([{ user_id: userId, picture_id: pictureId }]);
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
export const toggleLike = async (userId: string, pictureId: string, isLiked: boolean) => {
|
||||
const result = await apiClient<{ liked: boolean }>(`/api/pictures/${pictureId}/like`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, isLiked }),
|
||||
});
|
||||
return result.liked;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -179,22 +165,9 @@ export async function fetchUserMediaLikes(userId: string): Promise<{
|
||||
videoLikes: Set<string>;
|
||||
}> {
|
||||
try {
|
||||
const { supabase } = await import("@/integrations/supabase/client");
|
||||
// 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
|
||||
};
|
||||
const data = await apiClient<{ ids: string[] }>(`/api/me/likes?userId=${userId}`);
|
||||
const allLikedIds = new Set(data.ids || []);
|
||||
return { pictureLikes: allLikedIds, videoLikes: allLikedIds };
|
||||
} catch (error) {
|
||||
console.error('Error fetching user likes:', error);
|
||||
throw error;
|
||||
@ -202,7 +175,7 @@ export async function fetchUserMediaLikes(userId: string): Promise<{
|
||||
}
|
||||
|
||||
|
||||
export const fetchPictureById = async (id: string, client?: SupabaseClient) => {
|
||||
export const fetchPictureById = async (id: string) => {
|
||||
return fetchWithDeduplication(`picture-${id}`, async () => {
|
||||
try {
|
||||
return await apiClient<any>(`/api/pictures/${id}`);
|
||||
@ -221,7 +194,6 @@ export const fetchMediaItemsByIds = async (
|
||||
ids: string[],
|
||||
options?: {
|
||||
maintainOrder?: boolean;
|
||||
client?: SupabaseClient;
|
||||
}
|
||||
): Promise<MediaItem[]> => {
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@ -262,36 +234,40 @@ async function filterPrivateCollectionPictures(pictures: any[]): Promise<any[]>
|
||||
return pictures;
|
||||
}
|
||||
|
||||
export const unlinkPictures = async (ids: string[], client?: SupabaseClient) => {
|
||||
export const unlinkPictures = async (ids: string[]) => {
|
||||
return apiClient('/api/pictures/unlink', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
};
|
||||
|
||||
export const upsertPictures = async (pictures: Partial<PostMediaItem>[], client?: SupabaseClient) => {
|
||||
export const upsertPictures = async (pictures: Partial<PostMediaItem>[]) => {
|
||||
return apiClient('/api/pictures/upsert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pictures })
|
||||
});
|
||||
};
|
||||
export const addCollectionPictures = async (inserts: { collection_id: string, picture_id: string }[], client?: SupabaseClient) => {
|
||||
const { supabase } = await import("@/integrations/supabase/client");
|
||||
const db = client || supabase;
|
||||
const { error } = await db
|
||||
.from('collection_pictures')
|
||||
.insert(inserts);
|
||||
if (error) throw error;
|
||||
export const addCollectionPictures = async (inserts: { collection_id: string, picture_id: string }[]) => {
|
||||
await apiClient('/api/collection-pictures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inserts }),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateStorageFile = async (path: string, blob: Blob, client?: SupabaseClient) => {
|
||||
const { supabase } = await import("@/integrations/supabase/client");
|
||||
const db = client || supabase;
|
||||
const { error } = await db.storage.from('pictures').update(path, blob, {
|
||||
cacheControl: '0',
|
||||
upsert: true
|
||||
export const updateStorageFile = async (path: string, blob: Blob) => {
|
||||
const authHeaders = await getAuthHeaders();
|
||||
const headers = { ...authHeaders };
|
||||
delete (headers as any)['Content-Type'];
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('path', path);
|
||||
const { serverUrl } = await import('@/lib/db');
|
||||
const response = await fetch(`${serverUrl}/api/images/update-storage`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!response.ok) throw new Error(`Failed to update storage file: ${response.statusText}`);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -322,7 +298,7 @@ export const transformImage = async (file: File | Blob, operations: any[]): Prom
|
||||
return URL.createObjectURL(resultBlob);
|
||||
};
|
||||
|
||||
export const fetchSelectedVersions = async (rootIds: string[], client?: SupabaseClient) => {
|
||||
export const fetchSelectedVersions = async (rootIds: string[]) => {
|
||||
if (rootIds.length === 0) return [];
|
||||
|
||||
const sortedIds = [...rootIds].sort();
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
import { UserProfile } from "@/modules/posts/views/types";
|
||||
import { MediaType, MediaItem } from "@/types";
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { fetchWithDeduplication, getAuthToken } from "@/lib/db";
|
||||
|
||||
export interface FeedPost {
|
||||
@ -65,7 +63,7 @@ export const fetchPostsList = async (options: { page?: number; limit?: number; u
|
||||
return await res.json() as { data: any[]; count: number; page: number; limit: number };
|
||||
};
|
||||
|
||||
export const fetchPostById = async (id: string, client?: SupabaseClient) => {
|
||||
export const fetchPostById = async (id: string) => {
|
||||
// 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 () => {
|
||||
@ -76,21 +74,11 @@ export const fetchPostById = async (id: string, client?: SupabaseClient) => {
|
||||
};
|
||||
|
||||
|
||||
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 fetchFullPost = async (postId: string) => {
|
||||
return fetchWithDeduplication(`full-post-${postId}`, () => fetchPostDetailsAPI(postId));
|
||||
};
|
||||
|
||||
export const deletePost = async (id: string, client?: SupabaseClient) => {
|
||||
export const deletePost = async (id: string) => {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('No active session');
|
||||
|
||||
@ -127,7 +115,7 @@ export const createPost = async (postData: { title: string, description?: string
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updatePostDetails = async (postId: string, updates: { title?: string, description?: string, settings?: any, meta?: any }, client?: SupabaseClient) => {
|
||||
export const updatePostDetails = async (postId: string, updates: { title?: string, description?: string, settings?: any, meta?: any }) => {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('No active session');
|
||||
|
||||
@ -244,7 +232,7 @@ export const augmentFeedPosts = (posts: any[]): FeedPost[] => {
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePostMeta = async (postId: string, meta: any, client?: SupabaseClient) => {
|
||||
export const updatePostMeta = async (postId: string, meta: any) => {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('No active session');
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { User as UserIcon, LayoutGrid, StretchHorizontal, FileText, Save, X, Edit3, MoreVertical, Trash2, ArrowUp, ArrowDown, Heart, MessageCircle, Maximize, ImageIcon, Youtube, Music, Wand2, Map, Brush, Mail, Archive } from 'lucide-react';
|
||||
import { useOrganization } from "@/contexts/OrganizationContext";
|
||||
@ -30,11 +30,12 @@ const CommentCountButton = ({ pictureId, isOpen, onClick }: { pictureId: string,
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCount = async () => {
|
||||
const { count } = await supabase
|
||||
.from('comments')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('picture_id', pictureId);
|
||||
setCount(count);
|
||||
try {
|
||||
const data = await apiClient<{ comments: any[] }>(`/api/pictures/${pictureId}/comments`);
|
||||
setCount(data.comments?.length ?? null);
|
||||
} catch {
|
||||
setCount(null);
|
||||
}
|
||||
};
|
||||
fetchCount();
|
||||
}, [pictureId]);
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
|
||||
import { UserProfile } from "@/modules/posts/views/types";
|
||||
import { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { fetchWithDeduplication, getAuthToken as getZitadelToken } from "@/lib/db";
|
||||
import { fetchWithDeduplication, apiClient, getAuthToken as getZitadelToken } from "@/lib/db";
|
||||
|
||||
const serverUrl = (path: string) => {
|
||||
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
|
||||
@ -18,22 +16,14 @@ export const fetchProfileAPI = async (userId: string): Promise<{ profile: any; r
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const fetchAuthorProfile = async (userId: string, client?: SupabaseClient): Promise<UserProfile | null> => {
|
||||
const supabase = client || defaultSupabase;
|
||||
export const fetchAuthorProfile = async (userId: string): Promise<UserProfile | null> => {
|
||||
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;
|
||||
const result = await fetchProfileAPI(userId);
|
||||
return (result?.profile as UserProfile) ?? null;
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserSettings = async (userId: string, client?: SupabaseClient) => {
|
||||
export const getUserSettings = async (userId: string) => {
|
||||
return fetchWithDeduplication(`settings-${userId}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/settings'), {
|
||||
@ -44,7 +34,7 @@ export const getUserSettings = async (userId: string, client?: SupabaseClient) =
|
||||
}, 100000);
|
||||
};
|
||||
|
||||
export const updateUserSettings = async (userId: string, settings: any, client?: SupabaseClient) => {
|
||||
export const updateUserSettings = async (userId: string, settings: any) => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/settings'), {
|
||||
method: 'PATCH',
|
||||
@ -57,7 +47,7 @@ export const updateUserSettings = async (userId: string, settings: any, client?:
|
||||
if (!res.ok) throw new Error(`Failed to update settings: ${res.statusText}`);
|
||||
};
|
||||
|
||||
export const getUserOpenAIKey = async (userId: string, client?: SupabaseClient) => {
|
||||
export const getUserOpenAIKey = async (userId: string) => {
|
||||
return fetchWithDeduplication(`openai-${userId}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -72,7 +62,7 @@ export const getUserOpenAIKey = async (userId: string, client?: SupabaseClient)
|
||||
}
|
||||
|
||||
/** Get all API keys (masked) from server proxy */
|
||||
export const getUserApiKeys = async (userId: string, client?: SupabaseClient): Promise<Record<string, { masked: string | null; has_key: boolean }> | null> => {
|
||||
export const getUserApiKeys = async (userId: string): Promise<Record<string, { masked: string | null; has_key: boolean }> | null> => {
|
||||
return fetchWithDeduplication(`api-keys-${userId}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -84,7 +74,7 @@ export const getUserApiKeys = async (userId: string, client?: SupabaseClient): P
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserGoogleApiKey = async (userId: string, client?: SupabaseClient) => {
|
||||
export const getUserGoogleApiKey = async (userId: string) => {
|
||||
return fetchWithDeduplication(`google-${userId}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -97,7 +87,7 @@ export const getUserGoogleApiKey = async (userId: string, client?: SupabaseClien
|
||||
});
|
||||
}
|
||||
|
||||
export const getUserSecrets = async (userId: string, client?: SupabaseClient) => {
|
||||
export const getUserSecrets = async (userId: string) => {
|
||||
return fetchWithDeduplication(`user-secrets-${userId}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -109,7 +99,7 @@ export const getUserSecrets = async (userId: string, client?: SupabaseClient) =>
|
||||
});
|
||||
};
|
||||
|
||||
export const getProviderConfig = async (userId: string, provider: string, client?: SupabaseClient) => {
|
||||
export const getProviderConfig = async (userId: string, provider: string) => {
|
||||
return fetchWithDeduplication(`provider-${userId}-${provider}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl(`/api/me/provider-config/${encodeURIComponent(provider)}`), {
|
||||
@ -123,7 +113,7 @@ export const getProviderConfig = async (userId: string, provider: string, client
|
||||
|
||||
|
||||
|
||||
export const fetchUserRoles = async (userId: string, client?: SupabaseClient) => {
|
||||
export const fetchUserRoles = async (userId: string) => {
|
||||
return fetchWithDeduplication(`roles-${userId}`, async () => {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/roles'), {
|
||||
@ -137,6 +127,19 @@ export const fetchUserRoles = async (userId: string, client?: SupabaseClient) =>
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the resolved app identity in one call:
|
||||
* - `id` — app UUID (profiles.user_id) — use this everywhere in the app
|
||||
* - `sub` — original Zitadel numeric sub from the OIDC token
|
||||
* - `roles` — the user's roles array
|
||||
*
|
||||
* No cache key here since it's called once per auth session and
|
||||
* the result drives the AuthContext state.
|
||||
*/
|
||||
export const fetchUserIdentity = async (): Promise<{ id: string; sub: string; roles: string[] }> => {
|
||||
return apiClient<{ id: string; sub: string; roles: string[] }>('/api/me/identity');
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user secrets via server proxy (API keys are merged, other fields replaced).
|
||||
*/
|
||||
@ -219,7 +222,7 @@ export interface SavedShippingAddress {
|
||||
}
|
||||
|
||||
/** Get shipping addresses via server proxy */
|
||||
export const getShippingAddresses = async (userId: string, client?: SupabaseClient): Promise<SavedShippingAddress[]> => {
|
||||
export const getShippingAddresses = async (userId: string): Promise<SavedShippingAddress[]> => {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -235,7 +238,7 @@ export const getShippingAddresses = async (userId: string, client?: SupabaseClie
|
||||
};
|
||||
|
||||
/** Save shipping addresses via server proxy (full replace) */
|
||||
export const saveShippingAddresses = async (userId: string, addresses: SavedShippingAddress[], client?: SupabaseClient): Promise<void> => {
|
||||
export const saveShippingAddresses = async (userId: string, addresses: SavedShippingAddress[]): Promise<void> => {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -277,7 +280,7 @@ export interface VendorProfile {
|
||||
}
|
||||
|
||||
/** Get vendor profiles via server proxy */
|
||||
export const getVendorProfiles = async (userId: string, client?: SupabaseClient): Promise<VendorProfile[]> => {
|
||||
export const getVendorProfiles = async (userId: string): Promise<VendorProfile[]> => {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -293,7 +296,7 @@ export const getVendorProfiles = async (userId: string, client?: SupabaseClient)
|
||||
};
|
||||
|
||||
/** Save vendor profiles via server proxy (full replace) */
|
||||
export const saveVendorProfiles = async (userId: string, profiles: VendorProfile[], client?: SupabaseClient): Promise<void> => {
|
||||
export const saveVendorProfiles = async (userId: string, profiles: VendorProfile[]): Promise<void> => {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
const res = await fetch(serverUrl('/api/me/secrets'), {
|
||||
@ -334,10 +337,14 @@ export const updateProfileAPI = async (profileData: {
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
/** Update the current user's email (auth-level operation) */
|
||||
/** Update the current user's email via server API */
|
||||
export const updateUserEmail = async (newEmail: string): Promise<void> => {
|
||||
const { error } = await defaultSupabase.auth.updateUser({ email: newEmail });
|
||||
if (error) throw error;
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
await apiClient('/api/me/email', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ email: newEmail }),
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================
|
||||
|
||||
@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useOrganization } from '@/contexts/OrganizationContext';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Github, Mail } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Shield, LogOut } from 'lucide-react';
|
||||
import { LogIn, LogOut, Sparkles } from 'lucide-react';
|
||||
import { T, translate } from '@/i18n';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@ -12,26 +12,27 @@ const AuthZ = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
|
||||
// Monitor auth state changes and log them for debugging
|
||||
useEffect(() => {
|
||||
console.log("🛡️ [AuthZ] Auth State Update:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
hasError: !!auth.error,
|
||||
errorMsg: auth.error?.message
|
||||
});
|
||||
|
||||
if (auth.user) {
|
||||
// `profile.email` is the same claim the API reads from the Bearer JWT (`getUserCached` → `user.email`).
|
||||
console.log("🛡️ [AuthZ] Identity Token Profile:", auth.user.profile);
|
||||
console.log("🛡️ [AuthZ] Access Token:", auth.user.access_token);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("🛡️ [AuthZ] Auth State Update:", {
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
isLoading: auth.isLoading,
|
||||
hasError: !!auth.error,
|
||||
errorMsg: auth.error?.message
|
||||
});
|
||||
if (auth.user) {
|
||||
console.log("🛡️ [AuthZ] Identity Token Profile:", auth.user.profile);
|
||||
console.log("🛡️ [AuthZ] Access Token:", auth.user.access_token);
|
||||
}
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.isLoading, auth.error, auth.user]);
|
||||
|
||||
const handleZitadelLogin = async () => {
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await auth.signinRedirect();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectTo = params.get('redirectTo') || '/';
|
||||
await auth.signinRedirect({ state: { redirectTo } });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
@ -41,37 +42,43 @@ const AuthZ = () => {
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setSigningOut(true);
|
||||
await auth.signoutRedirect();
|
||||
await auth.signoutRedirect({ post_logout_redirect_uri: window.location.origin + '/' });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Signed in ─────────────────────────────────────────────────────────────
|
||||
if (auth.isAuthenticated && auth.user && !auth.isLoading) {
|
||||
const email = auth.user.profile.email ?? auth.user.profile.preferred_username ?? '';
|
||||
const name = auth.user.profile.given_name
|
||||
?? auth.user.profile.name
|
||||
?? auth.user.profile.preferred_username
|
||||
?? auth.user.profile.email
|
||||
?? '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-secondary/20 to-accent/20 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md glass-morphism border-white/20">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto bg-primary/10 w-12 h-12 rounded-full justify-center items-center flex mb-4">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">
|
||||
<T>Signed in</T>
|
||||
{name ? `${translate('Welcome back')}, ${name}!` : <T>Welcome back!</T>}
|
||||
</CardTitle>
|
||||
<CardDescription className="break-all">
|
||||
{email || <T>Session active</T>}
|
||||
<CardDescription>
|
||||
<T>You're all set. Let's get to work.</T>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button className="w-full" size="lg" onClick={() => navigate('/')}>
|
||||
<T>Continue to app</T>
|
||||
<T>Go to app</T>
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleLogout()}
|
||||
disabled={signingOut}
|
||||
>
|
||||
@ -84,34 +91,34 @@ const AuthZ = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sign in ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-secondary/20 to-accent/20 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md glass-morphism border-white/20">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto bg-primary/10 w-12 h-12 rounded-full justify-center items-center flex mb-4">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
<LogIn className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">
|
||||
<T>Corporate Login</T>
|
||||
<T>Welcome</T>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<T>Sign in securely via Zitadel Identity</T>
|
||||
<T>Sign in to continue</T>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
<T>You will be redirected to the secure portal to complete your authentication via Google or company credentials.</T>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
size="lg"
|
||||
onClick={handleZitadelLogin}
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? translate("Redirecting...") : translate("Continue to Security Portal")}
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
{isLoading ? translate('Redirecting…') : translate('Sign in')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
<T>Secure sign-in via Google or company credentials</T>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { uploadImage } from '@/lib/uploadUtils';
|
||||
import { createPicture } from '@/modules/posts/client-pictures';
|
||||
import { fetchCommentsAPI } from '@/modules/posts/client-pictures';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -129,44 +132,24 @@ const CollectionsContent = () => {
|
||||
|
||||
setUploadingFiles(prev => prev.map(f => f.id === fileToUpload.id ? { ...f, status: 'uploading', progress: 10 } : f));
|
||||
|
||||
const filePath = `${user.id}/${collection.id}/${Date.now()}_${fileToUpload.file.name.replace(/[^a-zA-Z0-9.\-_]/g, '')}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('pictures')
|
||||
.upload(filePath, fileToUpload.file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
});
|
||||
setUploadingFiles(prev => prev.map(f => f.id === fileToUpload.id ? { ...f, progress: 30, status: 'uploading' } : f));
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
const { publicUrl } = await uploadImage(fileToUpload.file, user.id);
|
||||
|
||||
setUploadingFiles(prev => prev.map(f => f.id === fileToUpload.id ? { ...f, progress: 90, status: 'processing' } : f));
|
||||
setUploadingFiles(prev => prev.map(f => f.id === fileToUpload.id ? { ...f, progress: 70, status: 'processing' } : f));
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage.from('pictures').getPublicUrl(filePath);
|
||||
const newPicture = await createPicture({
|
||||
user_id: user.id,
|
||||
image_url: publicUrl,
|
||||
thumbnail_url: publicUrl,
|
||||
title: '',
|
||||
description: '',
|
||||
} as any) as any;
|
||||
|
||||
if (!publicUrl) throw new Error('Could not get public URL');
|
||||
|
||||
const { data: newPicture, error: insertPictureError } = await supabase
|
||||
.from('pictures')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
image_url: publicUrl,
|
||||
thumbnail_url: publicUrl,
|
||||
title: '',
|
||||
description: '',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (insertPictureError) throw insertPictureError;
|
||||
|
||||
const { error: insertCollectionPictureError } = await supabase
|
||||
.from('collection_pictures')
|
||||
.insert({
|
||||
collection_id: collection.id,
|
||||
picture_id: newPicture.id,
|
||||
});
|
||||
|
||||
if (insertCollectionPictureError) throw insertCollectionPictureError;
|
||||
await apiClient('/api/collection-pictures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inserts: [{ collection_id: collection.id, picture_id: newPicture.id }] }),
|
||||
});
|
||||
|
||||
setUploadingFiles(prev => prev.map(f => f.id === fileToUpload.id ? { ...f, status: 'complete', progress: 100 } : f));
|
||||
|
||||
@ -206,56 +189,27 @@ const CollectionsContent = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch collection
|
||||
const { data: collectionData, error: collectionError } = await supabase
|
||||
.from('collections')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('slug', slug)
|
||||
.single();
|
||||
const collections = await apiClient<Collection[]>(`/api/collections?userId=${userId}&slug=${slug}`);
|
||||
const collectionData = collections?.[0];
|
||||
|
||||
if (collectionError) {
|
||||
console.error('Error fetching collection:', collectionError);
|
||||
if (!collectionData) {
|
||||
console.error('Collection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setCollection(collectionData);
|
||||
|
||||
// Fetch pictures in collection
|
||||
const { data: picturesData, error: picturesError } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select(`
|
||||
pictures:picture_id (
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
image_url,
|
||||
thumbnail_url,
|
||||
user_id,
|
||||
created_at,
|
||||
likes_count
|
||||
)
|
||||
`)
|
||||
.eq('collection_id', collectionData.id);
|
||||
const picturesData = await apiClient<Picture[]>(`/api/collection-pictures/pictures?collectionId=${collectionData.id}`);
|
||||
const flattenedPictures = picturesData || [];
|
||||
|
||||
if (picturesError) {
|
||||
console.error('Error fetching pictures:', picturesError);
|
||||
return;
|
||||
}
|
||||
|
||||
const flattenedPictures = picturesData
|
||||
.map(item => item.pictures)
|
||||
.filter(Boolean) as Picture[];
|
||||
|
||||
// Add comment counts for each picture
|
||||
const picturesWithCommentCounts = await Promise.all(
|
||||
flattenedPictures.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 }] };
|
||||
try {
|
||||
const result = await fetchCommentsAPI(picture.id);
|
||||
return { ...picture, comments: [{ count: result.comments?.length || 0 }] };
|
||||
} catch {
|
||||
return { ...picture, comments: [{ count: 0 }] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@ -288,38 +242,18 @@ const CollectionsContent = () => {
|
||||
if (!collection || !user) return;
|
||||
|
||||
try {
|
||||
// Find the picture that was just published by URL
|
||||
const { data: picture, error: findError } = await supabase
|
||||
.from('pictures')
|
||||
.select('id')
|
||||
.eq('image_url', imageUrl)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
const pics = await apiClient<any[]>(`/api/pictures?userId=${user.id}&imageUrl=${encodeURIComponent(imageUrl)}&limit=1`);
|
||||
const picture = pics?.[0];
|
||||
|
||||
if (findError || !picture) {
|
||||
console.error('Error finding published picture:', findError);
|
||||
if (!picture) {
|
||||
console.error('Could not find published picture');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the picture to this collection
|
||||
const { error: addError } = await supabase
|
||||
.from('collection_pictures')
|
||||
.insert({
|
||||
collection_id: collection.id,
|
||||
picture_id: picture.id
|
||||
});
|
||||
|
||||
if (addError) {
|
||||
console.error('Error adding to collection:', addError);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to add image to collection",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
await apiClient('/api/collection-pictures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inserts: [{ collection_id: collection.id, picture_id: picture.id }] }),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
@ -355,18 +289,15 @@ const CollectionsContent = () => {
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('collections')
|
||||
.update({
|
||||
await apiClient(`/api/collections/${collection.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim() || null,
|
||||
is_public: editForm.is_public,
|
||||
slug: newSlug
|
||||
})
|
||||
.eq('id', collection.id)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) throw error;
|
||||
slug: newSlug,
|
||||
}),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
@ -398,22 +329,11 @@ const CollectionsContent = () => {
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// First delete all collection_pictures entries
|
||||
const { error: deletePicturesError } = await supabase
|
||||
.from('collection_pictures')
|
||||
.delete()
|
||||
.eq('collection_id', collection.id);
|
||||
|
||||
if (deletePicturesError) throw deletePicturesError;
|
||||
|
||||
// Then delete the collection itself
|
||||
const { error: deleteError } = await supabase
|
||||
.from('collections')
|
||||
.delete()
|
||||
.eq('id', collection.id)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
await apiClient('/api/collection-pictures', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ collection_id: collection.id, deleteAll: true }),
|
||||
});
|
||||
await apiClient(`/api/collections/${collection.id}`, { method: 'DELETE' });
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
@ -568,7 +488,7 @@ const CollectionsContent = () => {
|
||||
|
||||
{/* Images Grid */}
|
||||
<PhotoGrid
|
||||
customPictures={pictures.map(p => ({ ...p, type: 'supabase-image', meta: {} }))}
|
||||
customPictures={pictures.map(p => ({ ...p, type: 'image', meta: {} }))}
|
||||
customLoading={loading}
|
||||
navigationSource="collection"
|
||||
navigationSourceId={collection?.id}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@ -46,38 +46,20 @@ const NewCollection = () => {
|
||||
|
||||
try {
|
||||
const slug = generateSlug(name);
|
||||
|
||||
// Check if slug already exists for this user
|
||||
const { data: existing } = await supabase
|
||||
.from('collections')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('slug', slug)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
toast.error("You already have a collection with this name");
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the collection
|
||||
const { data, error } = await supabase
|
||||
.from('collections')
|
||||
.insert({
|
||||
const data = await apiClient<any>('/api/collections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: user.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
slug: slug,
|
||||
is_public: isPublic
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
slug,
|
||||
is_public: isPublic,
|
||||
}),
|
||||
});
|
||||
|
||||
toast.success("Collection created successfully!");
|
||||
navigate(`/collections/${user.id}/${slug}`);
|
||||
navigate(`/collections/${user.id}/${data.slug || slug}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating collection:', error);
|
||||
toast.error("Failed to create collection");
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@ -30,29 +30,13 @@ export default function Organizations() {
|
||||
// Fetch organizations
|
||||
const { data: organizations, isLoading } = useQuery({
|
||||
queryKey: ["organizations"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("organizations")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as Organization[];
|
||||
},
|
||||
queryFn: () => apiClient<Organization[]>('/api/organizations'),
|
||||
});
|
||||
|
||||
// Create organization mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (org: { name: string; slug: string }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("organizations")
|
||||
.insert([org])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
mutationFn: (org: { name: string; slug: string }) =>
|
||||
apiClient<Organization>('/api/organizations', { method: 'POST', body: JSON.stringify(org) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
toast.success("Organization created successfully");
|
||||
@ -67,17 +51,8 @@ export default function Organizations() {
|
||||
|
||||
// Update organization mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (org: { id: string; name: string; slug: string }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("organizations")
|
||||
.update({ name: org.name, slug: org.slug })
|
||||
.eq("id", org.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
mutationFn: (org: { id: string; name: string; slug: string }) =>
|
||||
apiClient<Organization>(`/api/organizations/${org.id}`, { method: 'PATCH', body: JSON.stringify({ name: org.name, slug: org.slug }) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
toast.success("Organization updated successfully");
|
||||
@ -91,14 +66,7 @@ export default function Organizations() {
|
||||
|
||||
// Delete organization mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase
|
||||
.from("organizations")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
mutationFn: (id: string) => apiClient(`/api/organizations/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
toast.success("Organization deleted successfully");
|
||||
|
||||
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
||||
import { RotateCw, RotateCcw, Crop as CropIcon, Download, Sliders, Image as ImageIcon, X, Check, Save } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { fetchPictureById, updatePicture } from '@/modules/posts/client-pictures';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -46,13 +46,8 @@ const PlaygroundImageEditor = () => {
|
||||
setLoading(true);
|
||||
setPickerOpen(false);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('pictures')
|
||||
.select('*')
|
||||
.eq('id', pictureId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const data = await fetchPictureById(pictureId);
|
||||
if (!data) throw new Error('Picture not found');
|
||||
|
||||
setSelectedPictureId(pictureId);
|
||||
setSourceImage(data.image_url);
|
||||
@ -133,15 +128,7 @@ const PlaygroundImageEditor = () => {
|
||||
const { publicUrl } = await uploadImage(file, user.id);
|
||||
|
||||
// 3. Update Database Record
|
||||
const { error: dbError } = await supabase
|
||||
.from('pictures')
|
||||
.update({
|
||||
image_url: publicUrl,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', selectedPictureId);
|
||||
|
||||
if (dbError) throw dbError;
|
||||
await updatePicture(selectedPictureId, { image_url: publicUrl } as any);
|
||||
|
||||
setSourceImage(publicUrl);
|
||||
toast({ title: "Image Saved", description: "Source image updated successfully." });
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { fetchPictures, fetchCommentsAPI, toggleLike } from "@/modules/posts/client-pictures";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -60,54 +61,33 @@ const TagPage = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch pictures with tag in tags array or description
|
||||
const { data: picturesData, error: picturesError } = await supabase
|
||||
.from('pictures')
|
||||
.select('*')
|
||||
.or(`tags.cs.{${normalizedTag}},tags.cs.{"${normalizedTag}"},description.ilike.%#${normalizedTag}%,title.ilike.%#${normalizedTag}%`)
|
||||
.order('created_at', { ascending: false });
|
||||
const result = await fetchPictures({ tag: normalizedTag });
|
||||
const picturesData = result.data || [];
|
||||
const publicPictures = await filterPrivateCollectionPictures(picturesData);
|
||||
|
||||
if (picturesError) throw picturesError;
|
||||
|
||||
// Filter out pictures that are only in private collections
|
||||
const publicPictures = await filterPrivateCollectionPictures(picturesData || []);
|
||||
|
||||
// Get comment counts for each picture
|
||||
const picturesWithCommentCounts = 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 }] };
|
||||
try {
|
||||
const commentsResult = await fetchCommentsAPI(picture.id);
|
||||
return { ...picture, comments: [{ count: commentsResult.comments?.length || 0 }] };
|
||||
} catch {
|
||||
return { ...picture, comments: [{ count: 0 }] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setPictures(picturesWithCommentCounts);
|
||||
|
||||
// Fetch comments containing the hashtag with their associated pictures
|
||||
const { data: commentsData, error: commentsError } = await supabase
|
||||
.from('comments')
|
||||
.select('*')
|
||||
.ilike('content', `%#${normalizedTag}%`)
|
||||
.order('created_at', { ascending: false });
|
||||
const commentsData = await apiClient<any[]>(`/api/comments?tag=${encodeURIComponent('#' + normalizedTag)}`).catch(() => []);
|
||||
|
||||
if (commentsError) throw commentsError;
|
||||
|
||||
// Get picture details for each comment
|
||||
const commentsWithPictures = await Promise.all(
|
||||
(commentsData || []).map(async (comment) => {
|
||||
const { data: picture } = await supabase
|
||||
.from('pictures')
|
||||
.select('title, image_url')
|
||||
.eq('id', comment.picture_id)
|
||||
.single();
|
||||
|
||||
return {
|
||||
...comment,
|
||||
picture: picture || { title: 'Unknown', image_url: '' }
|
||||
};
|
||||
try {
|
||||
const picture = await apiClient<any>(`/api/pictures/${comment.picture_id}`);
|
||||
return { ...comment, picture: { title: picture?.title || 'Unknown', image_url: picture?.image_url || '' } };
|
||||
} catch {
|
||||
return { ...comment, picture: { title: 'Unknown', image_url: '' } };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@ -125,13 +105,8 @@ const TagPage = () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('likes')
|
||||
.select('picture_id')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) throw error;
|
||||
setUserLikes(new Set(data.map(like => like.picture_id)));
|
||||
const data = await apiClient<{ ids: string[] }>(`/api/me/likes?userId=${user.id}`);
|
||||
setUserLikes(new Set(data.ids || []));
|
||||
} catch (error) {
|
||||
console.error('Error fetching user likes:', error);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const UpdatePassword = () => {
|
||||
@ -35,21 +35,14 @@ const UpdatePassword = () => {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await supabase.auth.updateUser({ password });
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Update failed',
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Password updated',
|
||||
description: 'Your password has been changed successfully.',
|
||||
});
|
||||
try {
|
||||
await apiClient('/api/me/password', { method: 'PATCH', body: JSON.stringify({ password }) });
|
||||
toast({ title: 'Password updated', description: 'Your password has been changed successfully.' });
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
toast({ variant: 'destructive', title: 'Update failed', description: err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { apiClient } from "@/lib/db";
|
||||
import { fetchAuthorProfile } from "@/modules/user/client-user";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -45,16 +46,7 @@ const UserCollections = () => {
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError && profileError.code !== 'PGRST116') {
|
||||
throw profileError;
|
||||
}
|
||||
|
||||
const profile = await fetchAuthorProfile(userId!);
|
||||
if (!profile) {
|
||||
setUserProfile({
|
||||
id: userId!,
|
||||
@ -66,12 +58,12 @@ const UserCollections = () => {
|
||||
});
|
||||
} else {
|
||||
setUserProfile({
|
||||
id: profile.user_id,
|
||||
username: profile.username,
|
||||
display_name: profile.display_name || `User ${userId!.slice(0, 8)}`,
|
||||
bio: profile.bio,
|
||||
avatar_url: profile.avatar_url,
|
||||
created_at: profile.created_at,
|
||||
id: (profile as any).user_id || userId!,
|
||||
username: (profile as any).username || null,
|
||||
display_name: (profile as any).display_name || `User ${userId!.slice(0, 8)}`,
|
||||
bio: (profile as any).bio || null,
|
||||
avatar_url: (profile as any).avatar_url || null,
|
||||
created_at: (profile as any).created_at || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@ -83,30 +75,18 @@ const UserCollections = () => {
|
||||
|
||||
const fetchCollections = async () => {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('collections')
|
||||
.select('*');
|
||||
const params = new URLSearchParams({ userId: userId! });
|
||||
if (!isOwnProfile) params.set('is_public', 'true');
|
||||
const collectionsData = await apiClient<Collection[]>(`/api/collections?${params}`);
|
||||
|
||||
// If viewing own profile, show all collections; otherwise only public ones
|
||||
if (isOwnProfile) {
|
||||
query = query.eq('user_id', userId);
|
||||
} else {
|
||||
query = query.eq('user_id', userId).eq('is_public', true);
|
||||
}
|
||||
|
||||
const { data: collectionsData, error } = await query.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Get picture count for each collection
|
||||
const collectionsWithCounts = await Promise.all(
|
||||
(collectionsData || []).map(async (collection) => {
|
||||
const { count } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('collection_id', collection.id);
|
||||
|
||||
return { ...collection, picture_count: count || 0 };
|
||||
try {
|
||||
const data = await apiClient<{ picture_id: string }[]>(`/api/collection-pictures?collectionId=${collection.id}`);
|
||||
return { ...collection, picture_count: data?.length || 0 };
|
||||
} catch {
|
||||
return { ...collection, picture_count: 0 };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@ -1,521 +0,0 @@
|
||||
/**
|
||||
* Video Feed Playground
|
||||
* TikTok-style infinite scroll video player using our database videos
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { VideoFeed } from '@/player/components/VideoFeed';
|
||||
import { VideoItem } from '@/player/types';
|
||||
import { MediaItem } from '@/utils/mediaUtils';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { toast } from "sonner";
|
||||
import { fetchMediaItems } from '@/modules/posts/client-pictures';
|
||||
|
||||
// Mock data generators for TikTok features we don't have
|
||||
const SAMPLE_AVATARS = [
|
||||
'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100&h=100&fit=crop&crop=face',
|
||||
'https://images.unsplash.com/photo-1494790108755-2616b612b47c?w=100&h=100&fit=crop&crop=face',
|
||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop&crop=face',
|
||||
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=100&h=100&fit=crop&crop=face',
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=100&h=100&fit=crop&crop=face'
|
||||
];
|
||||
|
||||
const SAMPLE_MUSIC = [
|
||||
{
|
||||
id: 'music_1',
|
||||
title: 'Original Sound',
|
||||
playUrl: '',
|
||||
coverMedium: SAMPLE_AVATARS[0],
|
||||
authorName: 'Original Audio'
|
||||
},
|
||||
{
|
||||
id: 'music_2',
|
||||
title: 'Trending Sound',
|
||||
playUrl: '',
|
||||
coverMedium: SAMPLE_AVATARS[1],
|
||||
authorName: 'Popular Track'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Transform our MediaItem to TikTok's VideoItem format
|
||||
*/
|
||||
const transformMediaToVideoItem = (media: MediaItem, userProfiles: Record<string, any>): VideoItem => {
|
||||
const userProfile = userProfiles[media.user_id] || {};
|
||||
const isVideo = media.type === 'video';
|
||||
|
||||
// Extract hashtags from description
|
||||
const hashtags = media.description?.match(/#\w+/g) || [];
|
||||
|
||||
// Get display name with fallback priority: display_name > username > 'User'
|
||||
const displayName = userProfile.display_name
|
||||
|| userProfile.username
|
||||
|| `User ${media.user_id.slice(0, 8)}`;
|
||||
|
||||
const uniqueId = userProfile.username
|
||||
|| media.user_id.slice(0, 8);
|
||||
|
||||
return {
|
||||
id: media.id,
|
||||
author: {
|
||||
nickname: displayName,
|
||||
uniqueId: uniqueId,
|
||||
id: media.user_id,
|
||||
secUid: `sec_${media.user_id}`,
|
||||
// Use actual avatar or fallback to a consistent default based on user_id
|
||||
avatarThumb: userProfile.avatar_url || SAMPLE_AVATARS[parseInt(media.user_id.slice(-2), 16) % SAMPLE_AVATARS.length],
|
||||
verified: false
|
||||
},
|
||||
video: {
|
||||
width: isVideo ? (media.meta?.width || 720) : 720,
|
||||
height: isVideo ? (media.meta?.height || 1280) : 1280,
|
||||
duration: isVideo ? (media.meta?.duration || 30) : 30,
|
||||
ratio: '9:16',
|
||||
playAddr: media.url, // This will now correctly be either image_url or video_url
|
||||
downloadAddr: media.url,
|
||||
cover: media.thumbnail_url || media.url,
|
||||
dynamicCover: media.thumbnail_url || media.url
|
||||
},
|
||||
stats: {
|
||||
diggCount: media.likes_count || 0, // Real likes count
|
||||
playCount: Math.floor(Math.random() * 10000) + (media.likes_count * 10), // Mock, but proportional
|
||||
shareCount: Math.floor(media.comments_count / 2) + Math.floor(Math.random() * 50), // Mock, but proportional
|
||||
commentCount: media.comments_count || 0, // Real comments count
|
||||
collectCount: Math.floor(media.likes_count / 5) + Math.floor(Math.random() * 10) // Mock, but proportional
|
||||
},
|
||||
desc: media.description || media.title || 'Check out this content! ' + hashtags.slice(0, 3).join(' '),
|
||||
createTime: new Date(media.created_at).getTime(),
|
||||
isPinnedItem: false,
|
||||
// Mock data for TikTok features we don't support yet
|
||||
music: SAMPLE_MUSIC[Math.floor(Math.random() * SAMPLE_MUSIC.length)],
|
||||
challenges: hashtags.slice(0, 2).map((tag, idx) => ({
|
||||
id: `challenge_${idx}`,
|
||||
title: tag.replace('#', ''),
|
||||
desc: `Join the ${tag} challenge`
|
||||
})),
|
||||
effectStickers: [] // No effects for now
|
||||
};
|
||||
};
|
||||
|
||||
export default function VideoFeedPlayground() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [userProfiles, setUserProfiles] = useState<Record<string, any>>({});
|
||||
const [likedVideos, setLikedVideos] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use a ref to hold the latest userProfiles to avoid stale state in callbacks
|
||||
const userProfilesRef = useRef(userProfiles);
|
||||
userProfilesRef.current = userProfiles;
|
||||
|
||||
const VIDEOS_PER_PAGE = 10;
|
||||
|
||||
/**
|
||||
* Fetch user profiles for video authors
|
||||
* This function now only fetches and returns new profiles, and updates state separately.
|
||||
*/
|
||||
const fetchUserProfiles = useCallback(async (userIds: string[]) => {
|
||||
try {
|
||||
// Filter out user IDs that are already in our cache
|
||||
const uniqueUserIds = [...new Set(userIds)].filter(id => !userProfilesRef.current[id]);
|
||||
if (uniqueUserIds.length === 0) return {};
|
||||
|
||||
console.log('Fetching profiles for users:', uniqueUserIds);
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('user_id, avatar_url, display_name, username')
|
||||
.in('user_id', uniqueUserIds);
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const profileMap: Record<string, any> = {};
|
||||
data?.forEach(profile => {
|
||||
profileMap[profile.user_id] = profile;
|
||||
});
|
||||
|
||||
return profileMap;
|
||||
} catch (err) {
|
||||
console.error('Error fetching user profiles:', err);
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check which videos the current user has liked
|
||||
*/
|
||||
const checkLikedVideos = useCallback(async (videoIds: string[]) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('likes')
|
||||
.select('picture_id')
|
||||
.in('picture_id', videoIds)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const likedIds = new Set(data.map(like => like.picture_id));
|
||||
setLikedVideos(prev => new Set([...prev, ...likedIds]));
|
||||
} catch (err) {
|
||||
console.error('Error checking liked videos:', err);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
/**
|
||||
* Load initial videos
|
||||
*/
|
||||
const loadInitialVideos = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fallback to fetching from DB if no navigation data
|
||||
let initialMediaItems: MediaItem[] = [];
|
||||
|
||||
// If an ID is present in the URL, fetch that video first
|
||||
if (id) {
|
||||
const { data, error } = await supabase
|
||||
.from('pictures')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching specific video:', error);
|
||||
setError('Could not load the requested video.');
|
||||
// Continue to load the regular feed
|
||||
} else if (data) {
|
||||
// Manually construct a partial MediaItem to ensure the URL is correctly assigned
|
||||
const isMuxVideo = data.type === 'mux-video';
|
||||
const isLegacyVideo = data.type === 'video';
|
||||
|
||||
let url = data.image_url;
|
||||
if (isMuxVideo && data.meta?.mux_playback_id) {
|
||||
url = `https://stream.mux.com/${data.meta.mux_playback_id}.m3u8`;
|
||||
} else if (isLegacyVideo) {
|
||||
url = data.video_url;
|
||||
}
|
||||
|
||||
initialMediaItems.push({
|
||||
...data,
|
||||
url: url,
|
||||
} as MediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch more media items to fill the page
|
||||
const mediaItems = await fetchMediaItems({
|
||||
limit: VIDEOS_PER_PAGE,
|
||||
includePrivate: false,
|
||||
// Exclude the video we might have already fetched
|
||||
excludeIds: initialMediaItems.map(item => item.id)
|
||||
});
|
||||
|
||||
// Combine the specific video with the rest of the feed
|
||||
const allMediaItems = [...initialMediaItems, ...mediaItems.filter(item => !initialMediaItems.find(i => i.id === item.id))];
|
||||
|
||||
// Filter to only videos
|
||||
const videoItems = allMediaItems.filter(item => item.type === 'video');
|
||||
|
||||
if (videoItems.length === 0) {
|
||||
setError('No videos found. Please upload some videos to test the player.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user profiles and update state
|
||||
const userIds = videoItems.map(v => v.user_id);
|
||||
const newProfiles = await fetchUserProfiles(userIds);
|
||||
setUserProfiles(prev => ({ ...prev, ...newProfiles }));
|
||||
|
||||
// Check for liked status
|
||||
await checkLikedVideos(videoItems.map(v => v.id));
|
||||
|
||||
// Use the latest profiles for transformation
|
||||
const allProfiles = { ...userProfilesRef.current, ...newProfiles };
|
||||
const transformedVideos = videoItems.map(item =>
|
||||
transformMediaToVideoItem(item, allProfiles)
|
||||
);
|
||||
|
||||
setVideos(transformedVideos);
|
||||
setHasMore(videoItems.length >= VIDEOS_PER_PAGE);
|
||||
setCurrentPage(1);
|
||||
|
||||
// Set initial URL to the first video if not already on a specific video URL
|
||||
if (transformedVideos.length > 0 && !id) {
|
||||
navigate(`/video-feed/${transformedVideos[0].id}`, { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading videos:', err);
|
||||
setError('Failed to load videos. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, navigationData, fetchUserProfiles, checkLikedVideos, navigate]);
|
||||
|
||||
/**
|
||||
* Load more videos for infinite scroll
|
||||
*/
|
||||
const loadMoreVideos = useCallback(async () => {
|
||||
if (loading || !hasMore) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const mediaItems = await fetchMediaItems({
|
||||
limit: VIDEOS_PER_PAGE,
|
||||
includePrivate: false,
|
||||
offset: currentPage * VIDEOS_PER_PAGE
|
||||
});
|
||||
|
||||
const videoItems = mediaItems.filter(item => item.type === 'video');
|
||||
|
||||
if (videoItems.length < VIDEOS_PER_PAGE) {
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
// Fetch user profiles for new videos
|
||||
const userIds = videoItems.map(v => v.user_id);
|
||||
const newProfiles = await fetchUserProfiles(userIds);
|
||||
setUserProfiles(prev => ({ ...prev, ...newProfiles }));
|
||||
|
||||
// Check for liked status
|
||||
await checkLikedVideos(videoItems.map(v => v.id));
|
||||
|
||||
// Transform to TikTok format
|
||||
const allProfiles = { ...userProfilesRef.current, ...newProfiles };
|
||||
const transformedVideos = videoItems.map(item =>
|
||||
transformMediaToVideoItem(item, allProfiles)
|
||||
);
|
||||
|
||||
setVideos(prev => {
|
||||
const existingIds = new Set(prev.map(v => v.id));
|
||||
const newVideos = transformedVideos.filter(v => !existingIds.has(v.id));
|
||||
return [...prev, ...newVideos];
|
||||
});
|
||||
setCurrentPage(prev => prev + 1);
|
||||
} catch (err) {
|
||||
console.error('Error loading more videos:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, hasMore, currentPage, fetchUserProfiles, checkLikedVideos]);
|
||||
|
||||
// Load initial videos on mount
|
||||
useEffect(() => {
|
||||
// Only fetch videos if the list is empty. This prevents re-fetching when the URL
|
||||
// changes due to scrolling in the feed. The `id` from `useParams` is still respected
|
||||
// by `loadInitialVideos` on the first load.
|
||||
if (videos.length === 0) {
|
||||
loadInitialVideos();
|
||||
}
|
||||
}, [loadInitialVideos, videos.length]);
|
||||
|
||||
// Clear navigation data on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle video interactions
|
||||
*/
|
||||
const handleLike = useCallback(async (videoId: string) => {
|
||||
if (!user) {
|
||||
toast.error('Please sign in to like videos');
|
||||
return;
|
||||
}
|
||||
|
||||
const isLiked = likedVideos.has(videoId);
|
||||
|
||||
if (isLiked) {
|
||||
// UNLIKE LOGIC
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('likes')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('picture_id', videoId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Update state on success
|
||||
setLikedVideos(prev => {
|
||||
const newLiked = new Set(prev);
|
||||
newLiked.delete(videoId);
|
||||
return newLiked;
|
||||
});
|
||||
setVideos(prevVideos => {
|
||||
const newVideos = [...prevVideos];
|
||||
const videoIndex = newVideos.findIndex(v => v.id === videoId);
|
||||
if (videoIndex !== -1) {
|
||||
newVideos[videoIndex].stats.diggCount = Math.max(0, newVideos[videoIndex].stats.diggCount - 1);
|
||||
}
|
||||
return newVideos;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error unliking video:', error);
|
||||
toast.error('Failed to unlike video.');
|
||||
}
|
||||
} else {
|
||||
// LIKE LOGIC
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('likes')
|
||||
.insert([{ user_id: user.id, picture_id: videoId }]);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Update state on success
|
||||
setLikedVideos(prev => {
|
||||
const newLiked = new Set(prev);
|
||||
newLiked.add(videoId);
|
||||
return newLiked;
|
||||
});
|
||||
setVideos(prevVideos => {
|
||||
const newVideos = [...prevVideos];
|
||||
const videoIndex = newVideos.findIndex(v => v.id === videoId);
|
||||
if (videoIndex !== -1) {
|
||||
newVideos[videoIndex].stats.diggCount++;
|
||||
}
|
||||
return newVideos;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if ((error as any).code === '23505') {
|
||||
// This case means client state is out of sync with DB.
|
||||
// The video is already liked in DB. Just update client state.
|
||||
console.warn('Like already exists in DB, syncing client state.');
|
||||
setLikedVideos(prev => {
|
||||
const newLiked = new Set(prev);
|
||||
newLiked.add(videoId);
|
||||
return newLiked;
|
||||
});
|
||||
} else {
|
||||
console.error('Error liking video:', error);
|
||||
toast.error('Failed to like video.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [user, likedVideos]);
|
||||
|
||||
const handleComment = (videoId: string) => {
|
||||
console.log('Comment on video:', videoId);
|
||||
// This is handled by the VideoFeed component now
|
||||
};
|
||||
|
||||
const handleShare = (videoId: string) => {
|
||||
const videoUrl = `${window.location.origin}/video/${videoId}`;
|
||||
navigator.clipboard?.writeText(videoUrl).then(() => {
|
||||
console.log('Video URL copied:', videoUrl);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFollow = (userId: string) => {
|
||||
console.log('Follow user:', userId);
|
||||
// TODO: Implement follow functionality
|
||||
};
|
||||
|
||||
const handleAvatarClick = (userId: string) => {
|
||||
if (userId) {
|
||||
navigate(`/user/${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoChange = useCallback((index: number) => {
|
||||
if (videos && videos[index]) {
|
||||
const videoId = videos[index].id;
|
||||
// Update URL without navigating away. `replace: true` avoids polluting browser history.
|
||||
navigate(`/video-feed/${videoId}`, { replace: true });
|
||||
}
|
||||
}, [navigate, videos]);
|
||||
|
||||
if (error && videos.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex flex-col items-center justify-center text-white p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Video Feed Playground</h1>
|
||||
<div className="bg-red-900/20 border border-red-500 rounded-lg p-6 mb-6">
|
||||
<p className="text-red-200">{error}</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={() => navigate('/new')}
|
||||
className="w-full"
|
||||
>
|
||||
Upload a Video
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate('/')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && videos.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p>Loading videos...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-full overflow-hidden bg-black">
|
||||
{/* Back button overlay */}
|
||||
<div className="absolute top-4 left-4 z-50">
|
||||
<Button
|
||||
onClick={() => navigate('/')}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-black/50 hover:bg-black/70 text-white rounded-full"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Video Feed */}
|
||||
<VideoFeed
|
||||
videos={videos}
|
||||
likedVideos={likedVideos}
|
||||
onLoadMore={loadMoreVideos}
|
||||
hasMore={hasMore}
|
||||
loading={loading}
|
||||
autoplay={true}
|
||||
muted={true}
|
||||
onVideoChange={handleVideoChange}
|
||||
onLike={handleLike}
|
||||
onComment={handleComment}
|
||||
onShare={handleShare}
|
||||
onFollow={handleFollow}
|
||||
onAvatarClick={handleAvatarClick}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import { Loader2, AlertCircle, CheckCircle2, Image as ImageIcon, Upload } from "
|
||||
// Import from updated video-router
|
||||
import { generateVideo, AVAILABLE_VIDEO_MODELS, VideoGenerationOptions } from "@/lib/video-router";
|
||||
import { ImagePickerDialog } from "@/components/widgets/ImagePickerDialog";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { fetchPictureById } from "@/modules/posts/client-pictures";
|
||||
import { VideoSettingsControls } from "@/components/video/VideoSettingsControls";
|
||||
|
||||
const VideoGenPlayground = () => {
|
||||
@ -172,12 +172,7 @@ const VideoGenPlayground = () => {
|
||||
const handleImageSelect = async (pictureId: string) => {
|
||||
setShowImagePicker(false);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('pictures')
|
||||
.select('image_url')
|
||||
.eq('id', pictureId)
|
||||
.single();
|
||||
|
||||
const data = await fetchPictureById(pictureId);
|
||||
if (!data) return;
|
||||
|
||||
if (imagePickerTarget === 'first') {
|
||||
|
||||
@ -1,657 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import VideoCard from "@/components/VideoCard";
|
||||
// import MuxUploader from "@mux/mux-uploader-react";
|
||||
import { toast } from "sonner";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Loader2, Upload, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { MuxVideoMetadata, MuxTrack } from "@/types";
|
||||
|
||||
type UploadStatus = 'idle' | 'uploading' | 'processing' | 'ready' | 'error';
|
||||
|
||||
interface MuxAsset {
|
||||
id: string;
|
||||
status: 'preparing' | 'ready' | 'errored';
|
||||
duration?: number;
|
||||
aspect_ratio?: string;
|
||||
created_at?: string;
|
||||
max_stored_resolution?: string;
|
||||
max_stored_frame_rate?: number;
|
||||
tracks?: MuxTrack[];
|
||||
playback_ids?: Array<{
|
||||
id: string;
|
||||
policy: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const VideoPlayerPlayground = () => {
|
||||
const { user, session } = useAuth();
|
||||
const location = useLocation();
|
||||
const [videoUrl, setVideoUrl] = useState("https://files.vidstack.io/sprite-fight/720p.mp4");
|
||||
const [title, setTitle] = useState("Sprite Fight");
|
||||
const [description, setDescription] = useState("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState("");
|
||||
const [previewKey, setPreviewKey] = useState(0);
|
||||
|
||||
// Mux upload states
|
||||
const [uploadStatus, setUploadStatus] = useState<UploadStatus>('idle');
|
||||
const [uploadId, setUploadId] = useState<string | null>(null);
|
||||
const [assetId, setAssetId] = useState<string | null>(null);
|
||||
const [playbackId, setPlaybackId] = useState<string | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const uploaderRef = useRef<any>(null);
|
||||
|
||||
// Handle incoming video file from CreationWizard
|
||||
useEffect(() => {
|
||||
const state = location.state as { videoFile?: File } | null;
|
||||
if (state?.videoFile && user) {
|
||||
const file = state.videoFile;
|
||||
setTitle(file.name.replace(/\.[^/.]+$/, '')); // Remove extension
|
||||
setDescription(`Uploaded video: ${file.name}`);
|
||||
|
||||
// Automatically trigger upload
|
||||
toast.info('Ready to upload: ' + file.name);
|
||||
|
||||
// Note: The actual upload will be triggered when user drags/selects file in MuxUploader
|
||||
// We can't programmatically upload to MuxUploader, so we just pre-fill the metadata
|
||||
}
|
||||
}, [location.state, user]);
|
||||
|
||||
const handleUpdatePreview = () => {
|
||||
if (!videoUrl.trim()) {
|
||||
toast.error("Please enter a video URL");
|
||||
return;
|
||||
}
|
||||
setPreviewKey(prev => prev + 1);
|
||||
toast.success("Preview updated!");
|
||||
};
|
||||
// Poll for asset creation
|
||||
const pollForAsset = async (uploadId: string) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // Poll for up to 2 minutes
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/functions/v1/mux-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'get-upload',
|
||||
uploadId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data.asset_id) {
|
||||
// Asset created, now get asset details
|
||||
await getAssetDetails(result.data.asset_id);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(() => poll(), 2000); // Poll every 2 seconds
|
||||
} else {
|
||||
setUploadStatus('error');
|
||||
toast.error('Upload processing timed out');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling upload:', error);
|
||||
setUploadStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
};
|
||||
|
||||
// Get asset details including playback ID
|
||||
const getAssetDetails = async (assetId: string, attempts = 0) => {
|
||||
if (!session) return;
|
||||
|
||||
const maxAttempts = 60; // Poll for up to 2 minutes
|
||||
|
||||
try {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/functions/v1/mux-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'get-asset',
|
||||
assetId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const asset = result.data;
|
||||
console.log('Asset status:', asset.status, 'Attempt:', attempts + 1);
|
||||
|
||||
// Check if asset is ready
|
||||
if (asset.status === 'ready' && asset.playback_ids?.[0]) {
|
||||
const playbackId = asset.playback_ids[0].id;
|
||||
setAssetId(assetId);
|
||||
setPlaybackId(playbackId);
|
||||
setUploadStatus('ready');
|
||||
|
||||
// Set the video URL to Mux stream
|
||||
const muxVideoUrl = `https://stream.mux.com/${playbackId}.m3u8`;
|
||||
const thumbnailUrl = `https://image.mux.com/${playbackId}/thumbnail.jpg`;
|
||||
|
||||
setVideoUrl(muxVideoUrl);
|
||||
setThumbnailUrl(thumbnailUrl);
|
||||
setPreviewKey(prev => prev + 1);
|
||||
|
||||
// Auto-save to database with complete Mux metadata
|
||||
await saveVideoToDatabase(assetId, playbackId, uploadId || '', asset);
|
||||
|
||||
toast.success('Video is ready to play!');
|
||||
} else if (asset.status === 'preparing' && attempts < maxAttempts) {
|
||||
// Still processing, continue polling
|
||||
// Only show toast every 10 attempts to avoid spam
|
||||
if (attempts % 10 === 0) {
|
||||
toast.info(`Still processing... (${Math.round((attempts / maxAttempts) * 100)}%)`, {
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
setTimeout(() => getAssetDetails(assetId, attempts + 1), 2000);
|
||||
} else if (asset.status === 'errored') {
|
||||
setUploadStatus('error');
|
||||
toast.error('Video processing failed');
|
||||
} else if (attempts >= maxAttempts) {
|
||||
setUploadStatus('error');
|
||||
toast.error('Video processing timed out. It may still be processing - check your Mux dashboard.');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to get asset details');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting asset details:', error);
|
||||
|
||||
// Retry a few times for network errors
|
||||
if (attempts < 5) {
|
||||
setTimeout(() => getAssetDetails(assetId, attempts + 1), 3000);
|
||||
} else {
|
||||
setUploadStatus('error');
|
||||
toast.error('Failed to get video details');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadStart = () => {
|
||||
setUploadStatus('uploading');
|
||||
setUploadProgress(0);
|
||||
toast.info('Upload started...');
|
||||
};
|
||||
|
||||
const handleUploadProgress = (event: any) => {
|
||||
const detail = event?.detail;
|
||||
if (detail && typeof detail === 'number') {
|
||||
setUploadProgress(Math.round(detail * 100));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (event: any) => {
|
||||
const detail = event?.detail;
|
||||
console.log('Upload success:', detail);
|
||||
setUploadStatus('processing');
|
||||
toast.success('Upload complete! Processing video...');
|
||||
|
||||
if (uploadId) {
|
||||
pollForAsset(uploadId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadError = (event: any) => {
|
||||
const detail = event?.detail;
|
||||
console.error('Upload error:', detail);
|
||||
setUploadStatus('error');
|
||||
toast.error('Upload failed. Please try again.');
|
||||
};
|
||||
|
||||
const resetUpload = () => {
|
||||
setUploadStatus('idle');
|
||||
setUploadId(null);
|
||||
setAssetId(null);
|
||||
setPlaybackId(null);
|
||||
setUploadProgress(0);
|
||||
};
|
||||
|
||||
const saveVideoToDatabase = async (assetId: string, playbackId: string, uploadId: string, asset: MuxAsset) => {
|
||||
if (!user) {
|
||||
console.log('No user, skipping auto-save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if already saved
|
||||
const { data: existing } = await supabase
|
||||
.from('pictures')
|
||||
.select('id')
|
||||
.eq('meta->>mux_asset_id', assetId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
console.log('Video already saved to database');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare comprehensive metadata from Mux asset
|
||||
const metadata: MuxVideoMetadata = {
|
||||
mux_upload_id: uploadId,
|
||||
mux_asset_id: assetId,
|
||||
mux_playback_id: playbackId,
|
||||
duration: asset.duration ?? null,
|
||||
aspect_ratio: asset.aspect_ratio ?? null,
|
||||
created_at: asset.created_at ?? new Date().toISOString(),
|
||||
status: asset.status,
|
||||
max_stored_resolution: asset.max_stored_resolution ?? null,
|
||||
max_stored_frame_rate: asset.max_stored_frame_rate ?? null,
|
||||
tracks: asset.tracks ?? [],
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('pictures')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: title || 'Uploaded Video',
|
||||
description: description || null,
|
||||
image_url: `https://stream.mux.com/${playbackId}.m3u8`, // For videos, image_url stores the HLS URL
|
||||
thumbnail_url: `https://image.mux.com/${playbackId}/thumbnail.jpg`,
|
||||
type: 'mux-video', // Mark as video
|
||||
meta: metadata as any,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
console.log('Video auto-saved to database!');
|
||||
toast.success('Video saved to database!');
|
||||
} catch (error) {
|
||||
console.error('Error saving video:', error);
|
||||
toast.error('Failed to save video to database');
|
||||
}
|
||||
};
|
||||
|
||||
const saveToDatabase = async () => {
|
||||
if (!user || !assetId || !playbackId) {
|
||||
toast.error('Missing required information');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get latest asset details
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/functions/v1/mux-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session?.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'get-asset',
|
||||
assetId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
await saveVideoToDatabase(assetId, playbackId, uploadId || '', result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving video:', error);
|
||||
toast.error('Failed to save video');
|
||||
}
|
||||
};
|
||||
|
||||
const exampleVideos = [
|
||||
{
|
||||
name: "Sprite Fight (720p)",
|
||||
url: "https://files.vidstack.io/sprite-fight/720p.mp4",
|
||||
thumbnail: "https://files.vidstack.io/sprite-fight/poster.webp"
|
||||
},
|
||||
{
|
||||
name: "Sprite Fight (1080p)",
|
||||
url: "https://files.vidstack.io/sprite-fight/1080p.mp4",
|
||||
thumbnail: "https://files.vidstack.io/sprite-fight/poster.webp"
|
||||
},
|
||||
{
|
||||
name: "Big Buck Bunny",
|
||||
url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||
thumbnail: ""
|
||||
},
|
||||
{
|
||||
name: "Elephant's Dream",
|
||||
url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
|
||||
thumbnail: ""
|
||||
}
|
||||
];
|
||||
|
||||
const loadExample = (video: typeof exampleVideos[0]) => {
|
||||
setVideoUrl(video.url);
|
||||
setTitle(video.name);
|
||||
setThumbnailUrl(video.thumbnail);
|
||||
setPreviewKey(prev => prev + 1);
|
||||
toast.success(`Loaded: ${video.name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Video Player Playground</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Upload videos with Mux or test the Vidstack player with custom URLs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Controls Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Video Settings</CardTitle>
|
||||
<CardDescription>Upload or configure your video</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Tabs defaultValue="upload" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="upload">Upload Video</TabsTrigger>
|
||||
<TabsTrigger value="url">Test with URL</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
{!user && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Please sign in to upload videos
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upload-title">Video Title</Label>
|
||||
<Input
|
||||
id="upload-title"
|
||||
placeholder="My Awesome Video"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upload-description">Description</Label>
|
||||
<Input
|
||||
id="upload-description"
|
||||
placeholder="Video description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Upload Video</Label>
|
||||
|
||||
</div>
|
||||
|
||||
{uploadStatus !== 'idle' && (
|
||||
<div className="space-y-2">
|
||||
{uploadStatus === 'uploading' && (
|
||||
<Alert>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<AlertDescription>
|
||||
Uploading: {uploadProgress}%
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'processing' && (
|
||||
<Alert>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Processing video... This may take a few minutes.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mux is transcoding your video and creating HLS streams.
|
||||
You'll be notified when it's ready!
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'ready' && (
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>✅ Video ready and saved to database!</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Asset ID: {assetId}
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Upload failed. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={resetUpload}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Upload Another Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="url" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-url">Video URL *</Label>
|
||||
<Input
|
||||
id="video-url"
|
||||
type="url"
|
||||
placeholder="https://example.com/video.mp4"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url-title">Title</Label>
|
||||
<Input
|
||||
id="url-title"
|
||||
placeholder="My Video"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url-description">Description</Label>
|
||||
<Input
|
||||
id="url-description"
|
||||
placeholder="Video description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thumbnail">Thumbnail URL (optional)</Label>
|
||||
<Input
|
||||
id="thumbnail"
|
||||
type="url"
|
||||
placeholder="https://example.com/thumbnail.jpg"
|
||||
value={thumbnailUrl}
|
||||
onChange={(e) => setThumbnailUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleUpdatePreview} className="w-full">
|
||||
Update Preview
|
||||
</Button>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<Label className="mb-2 block">Example Videos</Label>
|
||||
<div className="space-y-2">
|
||||
{exampleVideos.map((video, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadExample(video)}
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
{video.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>See how your video will look</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VideoCard
|
||||
key={previewKey}
|
||||
videoId="preview-video"
|
||||
videoUrl={videoUrl}
|
||||
thumbnailUrl={thumbnailUrl || undefined}
|
||||
title={title || "Untitled Video"}
|
||||
author="Demo User"
|
||||
authorId="demo-user-id"
|
||||
likes={42}
|
||||
comments={7}
|
||||
isLiked={false}
|
||||
description={description}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About This Playground</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Vidstack Player</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Uses <strong>@vidstack/react</strong> for modern, customizable video playback.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Supports multiple video formats (MP4, WebM, HLS)</li>
|
||||
<li>Responsive design with mobile and desktop layouts</li>
|
||||
<li>Built-in controls: play, pause, volume, fullscreen, and more</li>
|
||||
<li>Keyboard shortcuts and accessibility support</li>
|
||||
<li>Picture-in-picture mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-semibold mb-2">Mux Upload</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Uses <strong>@mux/mux-uploader-react</strong> for professional video upload and streaming.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>Drag & drop or click to upload</li>
|
||||
<li>Automatic video processing and optimization</li>
|
||||
<li>Generates HLS streams for adaptive bitrate playback</li>
|
||||
<li>Automatic thumbnail generation</li>
|
||||
<li>Progress tracking and error handling</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{playbackId && (
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-semibold mb-2">Your Upload Details</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<Label>Asset ID:</Label>
|
||||
<code className="block bg-muted p-2 rounded text-xs break-all">
|
||||
{assetId}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Playback ID:</Label>
|
||||
<code className="block bg-muted p-2 rounded text-xs break-all">
|
||||
{playbackId}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Stream URL (HLS):</Label>
|
||||
<code className="block bg-muted p-2 rounded text-xs break-all">
|
||||
https://stream.mux.com/{playbackId}.m3u8
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Thumbnail URL:</Label>
|
||||
<code className="block bg-muted p-2 rounded text-xs break-all">
|
||||
https://image.mux.com/{playbackId}/thumbnail.jpg
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mux Player:</Label>
|
||||
<code className="block bg-muted p-2 rounded text-xs break-all">
|
||||
https://player.mux.com/{playbackId}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayerPlayground;
|
||||
|
||||
@ -1,54 +1,21 @@
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { apiClient } from '@/lib/db';
|
||||
|
||||
/**
|
||||
* Filters out pictures that are only in private collections.
|
||||
* Pictures that are:
|
||||
* - Not in any collection: SHOWN
|
||||
* - In at least one public collection: SHOWN
|
||||
* - Only in private collections: HIDDEN
|
||||
* Uses server API to check collection visibility.
|
||||
*/
|
||||
export async function filterPrivateCollectionPictures<T extends { id: string }>(
|
||||
pictures: T[]
|
||||
): Promise<T[]> {
|
||||
const filteredPictures = await Promise.all(
|
||||
pictures.map(async (picture) => {
|
||||
// Check if picture is in any collections
|
||||
const { data: collectionPictures, error: cpError } = await supabase
|
||||
.from('collection_pictures')
|
||||
.select('collection_id')
|
||||
.eq('picture_id', picture.id);
|
||||
|
||||
if (cpError) {
|
||||
console.error('Error checking collections:', cpError);
|
||||
return picture; // If error, include the picture
|
||||
}
|
||||
|
||||
// If not in any collection, show it
|
||||
if (!collectionPictures || collectionPictures.length === 0) {
|
||||
return picture;
|
||||
}
|
||||
|
||||
// Check if at least one collection is public
|
||||
const collectionIds = collectionPictures.map(cp => cp.collection_id);
|
||||
const { data: collections, error: collectionsError } = await supabase
|
||||
.from('collections')
|
||||
.select('is_public')
|
||||
.in('id', collectionIds);
|
||||
|
||||
if (collectionsError) {
|
||||
console.error('Error checking collection privacy:', collectionsError);
|
||||
return picture; // If error, include the picture
|
||||
}
|
||||
|
||||
// If at least one collection is public, show the picture
|
||||
const hasPublicCollection = collections?.some(c => c.is_public);
|
||||
|
||||
return hasPublicCollection ? picture : null;
|
||||
})
|
||||
);
|
||||
|
||||
// Remove null values (pictures that are only in private collections)
|
||||
return filteredPictures.filter((p): p is NonNullable<typeof p> => p !== null);
|
||||
if (pictures.length === 0) return pictures;
|
||||
try {
|
||||
const ids = pictures.map(p => p.id).join(',');
|
||||
const result = await apiClient<{ publicIds: string[] }>(`/api/collection-pictures/public-filter?ids=${ids}`);
|
||||
const publicIds = new Set(result.publicIds);
|
||||
return pictures.filter(p => publicIds.has(p.id));
|
||||
} catch {
|
||||
return pictures;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user