This commit is contained in:
lovebird 2026-04-09 21:52:49 +02:00
parent 6a1ef38863
commit 0ba36fb1d3
58 changed files with 538 additions and 2340 deletions

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -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)}`, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface Campaign {

View File

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

View File

@ -1,4 +1,3 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { fetchWithDeduplication } from "@/lib/db";
// ─── Types ────────────────────────────────────────────────────────────────────

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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') {

View File

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

View File

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