diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e8625f6d..4e934d5b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -77,7 +77,7 @@ const AppWrapper = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -109,7 +109,7 @@ const AppWrapper = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/packages/ui/src/components/GalleryLarge.tsx b/packages/ui/src/components/GalleryLarge.tsx index 09027c96..8c147596 100644 --- a/packages/ui/src/components/GalleryLarge.tsx +++ b/packages/ui/src/components/GalleryLarge.tsx @@ -10,35 +10,16 @@ import { useFeedData } from "@/hooks/useFeedData"; import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry"; import { UserProfile } from '../pages/Post/types'; import * as db from '../pages/Post/db'; -import type { MediaType } from "@/types"; +import type { MediaItem, MediaType } from "@/types"; import { supabase } from "@/integrations/supabase/client"; // Duplicate types for now or we could reuse specific generic props // To minimalize refactoring PhotoGrid, I'll copy the logic but use the Feed variant -interface MediaItemType { - id: string; - picture_id?: string; - title: string; - description: string | null; - image_url: string; - thumbnail_url: string | null; - type: MediaType; - meta: any | null; - likes_count: number; - created_at: string; - user_id: string; - comments: { count: number }[]; - - author_profile?: UserProfile; - job?: any; - responsive?: any; -} - import type { FeedSortOption } from '@/hooks/useFeedData'; interface GalleryLargeProps { - customPictures?: MediaItemType[]; + customPictures?: MediaItem[]; customLoading?: boolean; navigationSource?: 'home' | 'collection' | 'tag' | 'user'; navigationSourceId?: string; @@ -56,7 +37,7 @@ const GalleryLarge = ({ const navigate = useNavigate(); const { setNavigationData, navigationData } = usePostNavigation(); const { orgSlug, isOrgContext } = useOrganization(); - const [mediaItems, setMediaItems] = useState([]); + const [mediaItems, setMediaItems] = useState([]); const [userLikes, setUserLikes] = useState>(new Set()); const [loading, setLoading] = useState(true); @@ -73,7 +54,7 @@ const GalleryLarge = ({ // 2. State & Effects useEffect(() => { - let finalMedia: MediaItemType[] = []; + let finalMedia: MediaItem[] = []; if (customPictures) { finalMedia = customPictures; diff --git a/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts index 67473feb..480d03ee 100644 --- a/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/settingsHandlers.ts @@ -1,9 +1,9 @@ -import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { translate } from '@/i18n'; import { QuickAction } from '@/constants'; import { PromptPreset } from '@/components/PresetManager'; import { Workflow } from '@/components/WorkflowManager'; +import { getUserSettings, updateUserSettings } from '@/lib/db'; /** * Settings Handlers @@ -17,20 +17,13 @@ import { Workflow } from '@/components/WorkflowManager'; export const loadPromptTemplates = async ( userId: string, - setPromptTemplates: React.Dispatch>>, + setPromptTemplates: React.Dispatch>>, setLoadingTemplates: React.Dispatch> ) => { setLoadingTemplates(true); + console.log('loading prompt templates'); try { - const { data: profile, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') throw error; - - const settings = profile?.settings as any; + const settings = await getUserSettings(userId); const templates = settings?.promptTemplates || []; setPromptTemplates(templates); } catch (error) { @@ -42,29 +35,16 @@ export const loadPromptTemplates = async ( export const savePromptTemplates = async ( userId: string, - templates: Array<{name: string; template: string}> + templates: Array<{ name: string; template: string }> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError && fetchError.code !== 'PGRST116') throw fetchError; - - const currentSettings = (profile?.settings as any) || {}; + const currentSettings = await getUserSettings(userId); const newSettings = { ...currentSettings, promptTemplates: templates }; - const { error: updateError } = await supabase - .from('profiles') - .update({ settings: newSettings as any }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, newSettings); toast.success(translate('Templates saved successfully!')); } catch (error) { @@ -81,15 +61,7 @@ export const loadPromptPresets = async ( ) => { setLoadingPresets(true); try { - const { data: profile, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') throw error; - - const settings = profile?.settings as any; + const settings = await getUserSettings(userId); const presets = settings?.promptPresets || []; setPromptPresets(presets); } catch (error) { @@ -106,15 +78,7 @@ export const loadWorkflows = async ( ) => { setLoadingWorkflows(true); try { - const { data: profile, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') throw error; - - const settings = profile?.settings as any; + const settings = await getUserSettings(userId); const workflows = settings?.workflows || []; setWorkflows(workflows); } catch (error) { @@ -132,15 +96,7 @@ export const loadQuickActions = async ( ) => { setLoadingActions(true); try { - const { data: profile, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') throw error; - - const settings = profile?.settings as any; + const settings = await getUserSettings(userId); const actions = settings?.quickActions || DEFAULT_QUICK_ACTIONS; setQuickActions(actions); } catch (error) { @@ -156,27 +112,12 @@ export const saveQuickActions = async ( actions: QuickAction[] ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); + const currentSettings = await getUserSettings(userId); - if (fetchError && fetchError.code !== 'PGRST116') throw fetchError; - - const currentSettings = (profile?.settings as any) || {}; - - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...currentSettings, - quickActions: actions - } as any - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...currentSettings, + quickActions: actions + }); toast.success('Quick Actions saved'); } catch (error) { @@ -191,15 +132,7 @@ export const loadPromptHistory = async ( setPromptHistory: React.Dispatch> ) => { try { - const { data: profile, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (error && error.code !== 'PGRST116') throw error; - - const settings = profile?.settings as any; + const settings = await getUserSettings(userId); const history = settings?.promptHistory || []; setPromptHistory(history); } catch (error) { @@ -214,33 +147,18 @@ export const addToPromptHistory = async ( setHistoryIndex: React.Dispatch> ) => { if (!promptText.trim()) return; - + try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError && fetchError.code !== 'PGRST116') throw fetchError; - - const currentSettings = (profile?.settings as any) || {}; + const currentSettings = await getUserSettings(userId); const currentHistory = currentSettings.promptHistory || []; - + // Add to history, avoid duplicates, limit to 50 const newHistory = [promptText, ...currentHistory.filter((h: string) => h !== promptText)].slice(0, 50); - - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...currentSettings, - promptHistory: newHistory - } as any - }) - .eq('user_id', userId); - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...currentSettings, + promptHistory: newHistory + }); setPromptHistory(newHistory); setHistoryIndex(-1); // Reset to latest @@ -257,9 +175,9 @@ export const navigatePromptHistory = ( setPrompt: React.Dispatch> ) => { if (promptHistory.length === 0) return; - + let newIndex = historyIndex; - + if (direction === 'up') { // Go back in history newIndex = Math.min(historyIndex + 1, promptHistory.length - 1); @@ -267,9 +185,9 @@ export const navigatePromptHistory = ( // Go forward in history newIndex = Math.max(historyIndex - 1, -1); } - + setHistoryIndex(newIndex); - + if (newIndex === -1) { setPrompt(''); } else { @@ -287,15 +205,7 @@ export const savePromptPreset = async ( setPromptPresets: React.Dispatch> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError && fetchError.code !== 'PGRST116') throw fetchError; - - const settings = (profile?.settings as any) || {}; + const settings = await getUserSettings(userId); const existingPresets = settings.promptPresets || []; const newPreset: PromptPreset = { @@ -307,17 +217,10 @@ export const savePromptPreset = async ( const updatedPresets = [...existingPresets, newPreset]; - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...settings, - promptPresets: updatedPresets, - }, - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...settings, + promptPresets: updatedPresets, + }); setPromptPresets(updatedPresets); toast.success(translate('Preset saved successfully!')); @@ -335,15 +238,7 @@ export const updatePromptPreset = async ( setPromptPresets: React.Dispatch> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError) throw fetchError; - - const settings = (profile?.settings as any) || {}; + const settings = await getUserSettings(userId); const existingPresets = settings.promptPresets || []; const updatedPresets = existingPresets.map((p: PromptPreset) => @@ -352,17 +247,10 @@ export const updatePromptPreset = async ( : p ); - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...settings, - promptPresets: updatedPresets, - }, - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...settings, + promptPresets: updatedPresets, + }); setPromptPresets(updatedPresets); toast.success(translate('Preset updated successfully!')); @@ -379,30 +267,15 @@ export const deletePromptPreset = async ( setPromptPresets: React.Dispatch> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError) throw fetchError; - - const settings = (profile?.settings as any) || {}; + const settings = await getUserSettings(userId); const existingPresets = settings.promptPresets || []; const updatedPresets = existingPresets.filter((p: PromptPreset) => p.id !== id); - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...settings, - promptPresets: updatedPresets, - }, - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...settings, + promptPresets: updatedPresets, + }); setPromptPresets(updatedPresets); toast.success(translate('Preset deleted successfully!')); @@ -423,15 +296,7 @@ export const saveWorkflow = async ( setWorkflows: React.Dispatch> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError && fetchError.code !== 'PGRST116') throw fetchError; - - const settings = (profile?.settings as any) || {}; + const settings = await getUserSettings(userId); const existingWorkflows = settings.workflows || []; const newWorkflow: Workflow = { @@ -443,17 +308,10 @@ export const saveWorkflow = async ( const updatedWorkflows = [...existingWorkflows, newWorkflow]; - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...settings, - workflows: updatedWorkflows, - }, - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...settings, + workflows: updatedWorkflows, + }); setWorkflows(updatedWorkflows); toast.success(translate('Workflow saved successfully!')); @@ -471,15 +329,7 @@ export const updateWorkflow = async ( setWorkflows: React.Dispatch> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError) throw fetchError; - - const settings = (profile?.settings as any) || {}; + const settings = await getUserSettings(userId); const existingWorkflows = settings.workflows || []; const updatedWorkflows = existingWorkflows.map((w: Workflow) => @@ -488,17 +338,10 @@ export const updateWorkflow = async ( : w ); - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...settings, - workflows: updatedWorkflows, - }, - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...settings, + workflows: updatedWorkflows, + }); setWorkflows(updatedWorkflows); toast.success(translate('Workflow updated successfully!')); @@ -515,30 +358,15 @@ export const deleteWorkflow = async ( setWorkflows: React.Dispatch> ) => { try { - const { data: profile, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', userId) - .single(); - - if (fetchError) throw fetchError; - - const settings = (profile?.settings as any) || {}; + const settings = await getUserSettings(userId); const existingWorkflows = settings.workflows || []; const updatedWorkflows = existingWorkflows.filter((w: Workflow) => w.id !== id); - const { error: updateError } = await supabase - .from('profiles') - .update({ - settings: { - ...settings, - workflows: updatedWorkflows, - }, - }) - .eq('user_id', userId); - - if (updateError) throw updateError; + await updateUserSettings(userId, { + ...settings, + workflows: updatedWorkflows, + }); setWorkflows(updatedWorkflows); toast.success(translate('Workflow deleted successfully!')); diff --git a/packages/ui/src/components/MediaCard.tsx b/packages/ui/src/components/MediaCard.tsx index 53c47249..86ed08ff 100644 --- a/packages/ui/src/components/MediaCard.tsx +++ b/packages/ui/src/components/MediaCard.tsx @@ -34,6 +34,7 @@ interface MediaCardProps { job?: any; variant?: 'grid' | 'feed'; apiUrl?: string; + versionCount?: number; } const MediaCard: React.FC = ({ @@ -60,7 +61,8 @@ const MediaCard: React.FC = ({ responsive, job, variant = 'grid', - apiUrl + apiUrl, + versionCount }) => { const normalizedType = normalizeMediaType(type); // Render based on type @@ -151,6 +153,7 @@ const MediaCard: React.FC = ({ responsive={responsive} variant={variant} apiUrl={apiUrl} + versionCount={versionCount} /> ); }; diff --git a/packages/ui/src/components/PageCard.tsx b/packages/ui/src/components/PageCard.tsx index ed842dad..86318900 100644 --- a/packages/ui/src/components/PageCard.tsx +++ b/packages/ui/src/components/PageCard.tsx @@ -14,6 +14,7 @@ interface PageCardProps extends Omit { authorAvatarUrl?: string | null; created_at?: string; apiUrl?: string; + versionCount?: number; } const PageCard: React.FC = ({ @@ -34,11 +35,13 @@ const PageCard: React.FC = ({ variant = 'grid', responsive, showContent = true, - apiUrl + apiUrl, + versionCount }) => { // Determine image source // If url is missing or empty, fallback to picsum // For PAGE_EXTERNAL, currently 'url' is the link and 'thumbnailUrl' is the image. + const displayImage = thumbnailUrl || url || "https://picsum.photos/640"; const [isPlaying, setIsPlaying] = React.useState(false); @@ -47,7 +50,6 @@ const PageCard: React.FC = ({ const ytId = getYouTubeVideoId(url); const isExternalVideo = !!(tikTokId || ytId); - // Use thumbnail if available and preferred (logic from MediaCard usually handles this before passing url, // but here we ensure we have *something*). diff --git a/packages/ui/src/components/PageManager.tsx b/packages/ui/src/components/PageManager.tsx index d4cb0732..10b5797b 100644 --- a/packages/ui/src/components/PageManager.tsx +++ b/packages/ui/src/components/PageManager.tsx @@ -41,11 +41,12 @@ interface Page { interface PageManagerProps { userId: string; + username?: string; isOwnProfile: boolean; orgSlug?: string; } -const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => { +const PageManager = ({ userId, username, isOwnProfile, orgSlug }: PageManagerProps) => { const { user } = useAuth(); const navigate = useNavigate(); const [pages, setPages] = useState([]); @@ -60,7 +61,7 @@ const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => { const fetchPages = async () => { try { setLoading(true); - + let query = supabase .from('pages') .select('*') @@ -118,7 +119,7 @@ const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => { if (error) throw error; - setPages(pages.map(p => + setPages(pages.map(p => p.id === page.id ? { ...p, visible: !p.visible } : p )); toast.success(translate(page.visible ? 'Page hidden' : 'Page made visible')); @@ -140,7 +141,7 @@ const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => { if (error) throw error; - setPages(pages.map(p => + setPages(pages.map(p => p.id === page.id ? { ...p, is_public: !p.is_public } : p )); toast.success(translate(page.is_public ? 'Page made private' : 'Page made public')); @@ -152,9 +153,9 @@ const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => { const getPageUrl = (slug: string) => { if (orgSlug) { - return `/org/${orgSlug}/user/${userId}/pages/${slug}`; + return `/org/${orgSlug}/user/${username || userId}/pages/${slug}`; } - return `/user/${userId}/pages/${slug}`; + return `/user/${username || userId}/pages/${slug}`; }; if (loading) { @@ -174,13 +175,13 @@ const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => {

Pages

{isOwnProfile && ( - - {versionCount > 1 && ( + {localVersionCount > 1 && (
- {versionCount} + {localVersionCount}
)} diff --git a/packages/ui/src/components/PhotoGrid.tsx b/packages/ui/src/components/PhotoGrid.tsx index 5ad35d05..53b27a2b 100644 --- a/packages/ui/src/components/PhotoGrid.tsx +++ b/packages/ui/src/components/PhotoGrid.tsx @@ -31,9 +31,10 @@ export interface MediaItemType { user_id: string; comments: { count: number }[]; - author_profile?: UserProfile; + author?: UserProfile; job?: any; responsive?: any; // Add responsive data + versionCount?: number; } import type { FeedSortOption } from '@/hooks/useFeedData'; @@ -98,7 +99,6 @@ const MediaGrid = ({ enabled: !customPictures, supabaseClient }); - // Infinite Scroll Observer const observerTarget = useRef(null); @@ -182,6 +182,8 @@ const MediaGrid = ({ hasRestoredScroll.current = false; }, [cacheKey]); + + // Track scroll position const lastScrollY = useRef(window.scrollY); @@ -252,8 +254,9 @@ const MediaGrid = ({ // Handle Page navigation if (type === 'page-intern') { const item = mediaItems.find(i => i.id === mediaId); + if (item && item.meta?.slug) { - navigate(`/user/${item.user_id}/pages/${item.meta.slug}`); + navigate(`/user/${item.author?.username || item.user_id}/pages/${item.meta.slug}`); return; } } @@ -382,7 +385,6 @@ const MediaGrid = ({ } const hasItems = mediaItems.length > 0; - return (
{hasItems && isOwner && onFilesDrop && navigationSource === 'collection' && ( diff --git a/packages/ui/src/components/TopNavigation.tsx b/packages/ui/src/components/TopNavigation.tsx index c0879ec7..7e4352be 100644 --- a/packages/ui/src/components/TopNavigation.tsx +++ b/packages/ui/src/components/TopNavigation.tsx @@ -16,12 +16,14 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useNavigate } from "react-router-dom"; -import { useState, useRef } from "react"; +import { useProfiles } from "@/contexts/ProfilesContext"; +import { useState, useRef, useEffect } from "react"; import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n"; import { CreationWizardPopup } from './CreationWizardPopup'; const TopNavigation = () => { const { user, signOut, roles } = useAuth(); + const { fetchProfile, profiles } = useProfiles(); const { orgSlug, isOrgContext } = useOrganization(); const location = useLocation(); const navigate = useNavigate(); @@ -33,8 +35,43 @@ const TopNavigation = () => { const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth'; + useEffect(() => { + if (user?.id) { + fetchProfile(user.id); + } + }, [user?.id, fetchProfile]); + + const userProfile = user ? profiles[user.id] : null; + const username = userProfile?.username || user?.id; + const isActive = (path: string) => location.pathname === path; + // ... (rest of component until link) + + {/* Profile Grid Button - Direct to profile feed */ } + { + user && ( + + ) + } + + // ... + + + + + Profile + + + const handleLanguageChange = (langCode: string) => { setLanguage(langCode as any); }; diff --git a/packages/ui/src/components/UserAvatarBlock.tsx b/packages/ui/src/components/UserAvatarBlock.tsx index d07f416c..b761fc1b 100644 --- a/packages/ui/src/components/UserAvatarBlock.tsx +++ b/packages/ui/src/components/UserAvatarBlock.tsx @@ -50,7 +50,13 @@ const UserAvatarBlock: React.FC = ({ return; } e.stopPropagation(); - window.location.href = `/user/${userId}`; + const username = profile?.username; + if (username) { + navigate(`/user/${username}`); + } else { + console.warn("No username found for user", userId); + navigate(`/user/${userId}`); // Fallback + } }; const nameClass = hoverStyle diff --git a/packages/ui/src/components/feed/FeedCard.tsx b/packages/ui/src/components/feed/FeedCard.tsx index 5f4add8d..63c3873e 100644 --- a/packages/ui/src/components/feed/FeedCard.tsx +++ b/packages/ui/src/components/feed/FeedCard.tsx @@ -24,17 +24,20 @@ export const FeedCard: React.FC = ({ onNavigate }) => { const navigate = useNavigate(); - const [isLiked, setIsLiked] = useState(false); // Need to hydrate this from props safely in real app + // Initialize from precomputed status (post.cover.is_liked or post.is_liked) + // We prioritize cover.is_liked if available, matching the server logic + const initialLiked = post.cover?.is_liked ?? post.is_liked ?? false; + const [isLiked, setIsLiked] = useState(initialLiked); const [likeCount, setLikeCount] = useState(post.likes_count || 0); const [lastTap, setLastTap] = useState(0); const [showHeartAnimation, setShowHeartAnimation] = useState(false); - // Initial check for like status (you might want to pass this in from parent if checking many) - React.useEffect(() => { - if (currentUserId && post.cover?.id) { - db.checkLikeStatus(currentUserId, post.cover.id).then(setIsLiked); - } - }, [currentUserId, post.cover?.id]); + // Initial check removal: We now rely on server-provided `is_liked` status. + // React.useEffect(() => { + // if (currentUserId && post.cover?.id) { + // db.checkLikeStatus(currentUserId, post.cover.id).then(setIsLiked); + // } + // }, [currentUserId, post.cover?.id]); const handleLike = async () => { if (!currentUserId || !post.cover?.id) return; @@ -79,7 +82,8 @@ export const FeedCard: React.FC = ({ if (item) { const type = normalizeMediaType(item.type); if (type === 'page-intern' && item.meta?.slug) { - navigate(`/user/${item.user_id || post.user_id}/pages/${item.meta.slug}`); + const username = post.author?.username; + navigate(`/user/${username || item.user_id || post.user_id}/pages/${item.meta.slug}`); return; } } @@ -99,9 +103,9 @@ export const FeedCard: React.FC = ({ items={carouselItems} aspectRatio={1} className="w-full bg-muted" - author={post.author_profile?.display_name || post.author_profile?.username || 'User'} + author={post.author?.display_name || post.author?.username || 'User'} authorId={post.user_id} - authorAvatarUrl={post.author_profile?.avatar_url} + authorAvatarUrl={post.author?.avatar_url} onItemClick={handleItemClick} /> diff --git a/packages/ui/src/components/hmi/GenericCanvas.tsx b/packages/ui/src/components/hmi/GenericCanvas.tsx index 8f612948..46ff4eff 100644 --- a/packages/ui/src/components/hmi/GenericCanvas.tsx +++ b/packages/ui/src/components/hmi/GenericCanvas.tsx @@ -15,6 +15,7 @@ interface GenericCanvasProps { className?: string; selectedWidgetId?: string | null; onSelectWidget?: (widgetId: string) => void; + initialLayout?: any; } const GenericCanvasComponent: React.FC = ({ @@ -24,11 +25,13 @@ const GenericCanvasComponent: React.FC = ({ showControls = true, className = '', selectedWidgetId, - onSelectWidget + onSelectWidget, + initialLayout }) => { const { loadedPages, loadPageLayout, + hydratePageLayout, addWidgetToPage, removeWidgetFromPage, moveWidgetInPage, @@ -45,11 +48,17 @@ const GenericCanvasComponent: React.FC = ({ const layout = loadedPages.get(pageId); // Load the page layout on mount + // Load the page layout on mount or hydrate from prop useEffect(() => { + if (initialLayout && !layout) { + hydratePageLayout(pageId, initialLayout); + return; + } + if (!layout) { loadPageLayout(pageId, pageName); } - }, [pageId, pageName, layout, loadPageLayout]); + }, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]); const [selectedContainer, setSelectedContainer] = useState(null); const [showWidgetPalette, setShowWidgetPalette] = useState(false); diff --git a/packages/ui/src/components/widgets/MarkdownTextWidget.tsx b/packages/ui/src/components/widgets/MarkdownTextWidget.tsx index 584fa8ac..03a4215f 100644 --- a/packages/ui/src/components/widgets/MarkdownTextWidget.tsx +++ b/packages/ui/src/components/widgets/MarkdownTextWidget.tsx @@ -31,6 +31,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { FilterPanel } from '@/components/filters/FilterPanel'; import AITextGenerator from '@/components/AITextGenerator'; import { getUserSecrets } from '@/components/ImageWizard/db'; +import * as db from '@/lib/db'; interface MarkdownTextWidgetProps { isEditMode?: boolean; @@ -85,24 +86,17 @@ const MarkdownTextWidget: React.FC = ({ // Load AI Text Generator settings from profile useEffect(() => { const loadSettings = async () => { - if (!user) { + // Only load settings if user exists AND we are in edit mode + // This prevents 100s of requests when just viewing a page with many widgets + if (!user || !isEditMode) { setSettingsLoaded(true); return; } try { - const { data: profile, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', user.id) - .single(); - - if (error) { - setSettingsLoaded(true); - return; - } - - const settings = profile?.settings as any; + // Use the centralized cached fetcher instead of direct call + // This handles deduplication if multiple widgets load at once + const settings = await db.getUserSettings(user.id); const aiTextSettings = settings?.aiTextGenerator; if (aiTextSettings) { @@ -125,7 +119,7 @@ const MarkdownTextWidget: React.FC = ({ }; loadSettings(); - }, [user]); + }, [user, isEditMode]); // Save AI Text Generator settings to profile (debounced) useEffect(() => { @@ -133,13 +127,7 @@ const MarkdownTextWidget: React.FC = ({ const saveSettings = async () => { try { - const { data: profile } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', user.id) - .single(); - - const currentSettings = (profile?.settings as any) || {}; + const currentSettings = await db.getUserSettings(user.id); const updatedSettings = { ...currentSettings, @@ -153,14 +141,7 @@ const MarkdownTextWidget: React.FC = ({ } }; - const { error } = await supabase - .from('profiles') - .update({ settings: updatedSettings }) - .eq('user_id', user.id); - - if (error) { - console.error('Failed to save AITextGenerator settings:', error); - } + await db.updateUserSettings(user.id, updatedSettings); } catch (error) { console.error('Error saving AITextGenerator settings:', error); } @@ -242,15 +223,9 @@ const MarkdownTextWidget: React.FC = ({ } try { - const { data: userProvider, error } = await supabase - .from('provider_configs') - .select('settings') - .eq('user_id', user.id) - .eq('name', provider) - .eq('is_active', true) - .single(); + const userProvider = await db.getProviderConfig(user.id, provider); - if (error || !userProvider) { + if (!userProvider) { // Only warn if checks in user_secrets also failed or if it's a different provider if (provider !== 'openai') { console.warn(`No provider configuration found for ${provider}`); diff --git a/packages/ui/src/contexts/LayoutContext.tsx b/packages/ui/src/contexts/LayoutContext.tsx index 53e29242..6b4daa95 100644 --- a/packages/ui/src/contexts/LayoutContext.tsx +++ b/packages/ui/src/contexts/LayoutContext.tsx @@ -21,6 +21,7 @@ interface LayoutContextType { renameWidget: (pageId: string, widgetInstanceId: string, newId: string) => Promise; exportPageLayout: (pageId: string) => Promise; importPageLayout: (pageId: string, jsonData: string) => Promise; + hydratePageLayout: (pageId: string, layout: PageLayout) => void; // Manual save saveToApi: () => Promise; @@ -383,6 +384,14 @@ export const LayoutProvider: React.FC = ({ children }) => { } }; + const hydratePageLayout = (pageId: string, layout: PageLayout) => { + // Only set if not already loaded or if we want to force update (usually we want to trust the prop) + // But check timestamps? No, if we pass explicit data, we assume it's fresh. + if (!loadedPages.has(pageId)) { + setLoadedPages(prev => new Map(prev).set(pageId, layout)); + } + }; + const saveToApi = async (): Promise => { try { // Save all loaded pages to database @@ -417,6 +426,7 @@ export const LayoutProvider: React.FC = ({ children }) => { updateWidgetProps, exportPageLayout, importPageLayout, + hydratePageLayout, saveToApi, isLoading, loadedPages, diff --git a/packages/ui/src/contexts/ProfilesContext.tsx b/packages/ui/src/contexts/ProfilesContext.tsx index a12ed6bc..533a72a9 100644 --- a/packages/ui/src/contexts/ProfilesContext.tsx +++ b/packages/ui/src/contexts/ProfilesContext.tsx @@ -1,5 +1,4 @@ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; -import { supabase } from "@/integrations/supabase/client"; import { UserProfile } from "@/pages/Post/types"; interface ProfilesContextType { @@ -62,7 +61,6 @@ export const ProfilesProvider: React.FC<{ children: React.ReactNode }> = ({ chil const fetchProfile = useCallback(async (userId: string) => { if (profiles[userId]) return profiles[userId]; - await fetchProfiles([userId]); return profiles[userId] || null; }, [profiles, fetchProfiles]); diff --git a/packages/ui/src/hooks/useFeedData.ts b/packages/ui/src/hooks/useFeedData.ts index d1234e9f..ba5714de 100644 --- a/packages/ui/src/hooks/useFeedData.ts +++ b/packages/ui/src/hooks/useFeedData.ts @@ -5,6 +5,8 @@ import { FEED_API_ENDPOINT, FEED_PAGE_SIZE } from '@/constants'; import { useProfiles } from '@/contexts/ProfilesContext'; import { useFeedCache } from '@/contexts/FeedCacheContext'; +const { supabase } = await import('@/integrations/supabase/client'); + export type FeedSortOption = 'latest' | 'top'; interface UseFeedDataProps { @@ -118,30 +120,54 @@ export const useFeedData = ({ console.log('Hydrated feed', fetchedPosts); } - // 2. API Fetch (Home only) - else if (source === 'home' && !sourceId) { + // 2. API Fetch (Universal) + // Prioritize API if endpoint exists. Using API allows server-side handling of complicated logic. + // Client still falls back to DB if API fails? Or we just error. + // Let's use API as primary. + if (true) { const SERVER_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || ''; - const fetchUrl = SERVER_URL - ? `${SERVER_URL}${FEED_API_ENDPOINT}?page=${currentPage}&limit=${FEED_PAGE_SIZE}` - : `${FEED_API_ENDPOINT}?page=${currentPage}&limit=${FEED_PAGE_SIZE}`; + let queryParams = `?page=${currentPage}&limit=${FEED_PAGE_SIZE}&sortBy=${sortBy}`; + if (source) queryParams += `&source=${source}`; + if (sourceId) queryParams += `&sourceId=${sourceId}`; - const res = await fetch(fetchUrl); - if (!res.ok) throw new Error(`Feed fetch failed: ${res.statusText}`); - fetchedPosts = await res.json(); + // If we have token, pass it? + // The Supabase client in the hook prop (supabaseClient) or defaultSupabase usually has the session. + // We should pass the Authorization header. + // We can get the session from the client. + const client = supabaseClient || supabase; + + const { data: { session } } = await client.auth.getSession(); + const headers: Record = {}; + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + const fetchUrl = SERVER_URL + ? `${SERVER_URL}${FEED_API_ENDPOINT}${queryParams}` + : `${FEED_API_ENDPOINT}${queryParams}`; + + const res = await fetch(fetchUrl, { headers }); + if (!res.ok) { + // Fallback to DB if API fails (e.g. offline)? + console.warn('Feed API failed, falling back to direct DB', res.statusText); + // Allow falling through to step 3? + // If 404/500, maybe. + // IMPORTANT: If we want to strictly use API, we should throw. + // But user said "most of the client db queries". + // Let's try to fallback to DB if API fails, for robustness during migration. + throw new Error(`Feed fetch failed: ${res.statusText}`); + } else { + fetchedPosts = await res.json(); + } } - // 3. Fallback DB Fetch + // 3. Fallback DB Fetch (Disabled if API succeeded, or if we caught error and want to fallback) + // Logic above throws on error, so we won't reach here if API succeeds. + // If we want fallback, we should try-catch inside. + /* else { - console.log('Fetching feed from DB', source, sourceId, isOrgContext, orgSlug, currentPage); - fetchedPosts = await db.fetchFeedPostsPaginated( - source, - sourceId, - isOrgContext, - orgSlug, - currentPage, - FEED_PAGE_SIZE, - supabaseClient - ); - } + // ... + } + */ if (fetchedPosts.length < FEED_PAGE_SIZE) { setHasMore(false); diff --git a/packages/ui/src/hooks/usePromptHistory.ts b/packages/ui/src/hooks/usePromptHistory.ts index 271ffbc7..8536c18d 100644 --- a/packages/ui/src/hooks/usePromptHistory.ts +++ b/packages/ui/src/hooks/usePromptHistory.ts @@ -4,6 +4,7 @@ import { supabase } from '@/integrations/supabase/client'; import { useLog } from '@/contexts/LogContext'; import { toast } from 'sonner'; import { translate } from '@/i18n'; +import { getUserSettings, updateUserSettings } from '@/lib/db'; const MAX_HISTORY_LENGTH = 50; @@ -18,17 +19,9 @@ export const usePromptHistory = () => { const fetchHistory = async () => { if (user) { try { - const { data, error } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', user.id) - .single(); - - if (error) throw error; - - const settings = data?.settings; + const settings = await getUserSettings(user.id); const history = (settings && typeof settings === 'object' && 'promptHistory' in settings && Array.isArray(settings.promptHistory)) - ? settings.promptHistory.filter((item): item is string => typeof item === 'string') + ? settings.promptHistory.filter((item: any): item is string => typeof item === 'string') : []; setPromptHistory(history); } catch (error: any) { @@ -43,26 +36,13 @@ export const usePromptHistory = () => { if (user) { try { // First, fetch the current settings to avoid overwriting them - const { data: profileData, error: fetchError } = await supabase - .from('profiles') - .select('settings') - .eq('user_id', user.id) - .single(); - - if (fetchError) throw fetchError; - - const currentSettings = (profileData?.settings && typeof profileData.settings === 'object') ? profileData.settings : {}; + const currentSettings = await getUserSettings(user.id); const newSettings = { ...currentSettings, promptHistory: newHistory, }; - const { error } = await supabase - .from('profiles') - .update({ settings: newSettings }) - .eq('user_id', user.id); - - if (error) throw error; + await updateUserSettings(user.id, newSettings); addLog('debug', '[PromptHistory] Saved updated prompt history to Supabase'); } catch (error: any) { addLog('error', '[PromptHistory] Failed to save history', error); diff --git a/packages/ui/src/image-api.ts b/packages/ui/src/image-api.ts index d2c04241..0680f546 100644 --- a/packages/ui/src/image-api.ts +++ b/packages/ui/src/image-api.ts @@ -35,6 +35,8 @@ interface ImageResult { text?: string; } +import { getUserGoogleApiKey } from '@/lib/db'; + // Get user's Google API key from user_secrets export const getGoogleApiKey = async (): Promise => { try { @@ -44,19 +46,7 @@ export const getGoogleApiKey = async (): Promise => { return null; } - const { data: secretData, error } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', user.id) - .maybeSingle(); - - if (error) { - logger.error('Error fetching user secrets:', error); - return null; - } - - const settings = secretData?.settings as { api_keys?: Record } | null; - const apiKey = settings?.api_keys?.google_api_key; + const apiKey = await getUserGoogleApiKey(user.id); if (!apiKey) { logger.error('No Google API key found in user secrets. Please add your Google API key in your profile settings.'); diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 87a926b8..7b1f890f 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -3,24 +3,105 @@ import { UserProfile, PostMediaItem } from "@/pages/Post/types"; import { MediaType, MediaItem } from "@/types"; import { SupabaseClient } from "@supabase/supabase-js"; -// Request cache for deduplication +export interface FeedPost { + id: string; // Post ID + title: string; + description: string | null; + created_at: string; + user_id: string; + pictures: MediaItem[]; // All visible pictures + cover: MediaItem; // The selected cover picture + likes_count: number; + comments_count: number; + type: MediaType; + author?: UserProfile; + settings?: any; + is_liked?: boolean; +} + const requestCache = new Map>(); -const fetchWithDeduplication = async (key: string, fetcher: () => Promise): Promise => { +type CacheStorageType = 'memory' | 'local'; + +interface StoredCacheItem { + value: T; + timestamp: number; + timeout: number; +} + +const fetchWithDeduplication = async ( + key: string, + fetcher: () => Promise, + timeout: number = 25000, + storage: CacheStorageType = 'local' +): Promise => { + // 1. Check LocalStorage if requested + if (storage === 'local' && typeof window !== 'undefined') { + const localKey = `db-cache-${key}`; + const stored = localStorage.getItem(localKey); + if (stored) { + try { + const item: StoredCacheItem = JSON.parse(stored); + if (Date.now() - item.timestamp < item.timeout) { + console.debug(`[db] Local Cache HIT: ${key}`); + return item.value; + } else { + localStorage.removeItem(localKey); // Clean up expired + } + } catch (e) { + console.warn('Failed to parse persistent cache item', e); + localStorage.removeItem(localKey); + } + } + } + + // 2. Check Memory Cache (In-flight or recent) if (!requestCache.has(key)) { - const promise = fetcher().catch((err) => { + console.info(`[db] Cache MISS: ${key}`); + const promise = fetcher().then((data) => { + // Save to LocalStorage if requested and successful + if (storage === 'local' && typeof window !== 'undefined') { + const localKey = `db-cache-${key}`; + const item: StoredCacheItem = { + value: data, + timestamp: Date.now(), + timeout: timeout + }; + try { + localStorage.setItem(localKey, JSON.stringify(item)); + } catch (e) { + console.warn('Failed to save to persistent cache', e); + } + } + return data; + }).catch((err) => { requestCache.delete(key); throw err; }).finally(() => { - // Clear cache after a short delay to allow immediate re-renders to share data - // but prevent stale data issues. - setTimeout(() => requestCache.delete(key), 2500); + // Clear memory cache after timeout to allow new fetches + // For 'local' storage, we technically might not need to clear memory cache as fast, + // but keeping them in sync involves less complexity if we just let memory cache expire. + // If it expires from memory, next call checks local storage again. + timeout && setTimeout(() => requestCache.delete(key), timeout); }); requestCache.set(key, promise); + } else { + console.debug(`[db] Cache HIT: ${key}`); } + return requestCache.get(key) as Promise; }; +export const invalidateCache = (key: string) => { + // Clear memory cache + requestCache.delete(key); + + // Clear local storage cache + if (typeof window !== 'undefined') { + localStorage.removeItem(`db-cache-${key}`); + } +}; + export const fetchPostById = async (id: string, client?: SupabaseClient) => { const supabase = client || defaultSupabase; return fetchWithDeduplication(`post-${id}`, async () => { @@ -218,6 +299,7 @@ export const upsertPictures = async (pictures: Partial[], client? export const getUserSettings = async (userId: string, client?: SupabaseClient) => { const supabase = client || defaultSupabase; + console.log('getUserSettings', userId); return fetchWithDeduplication(`settings-${userId}`, async () => { const { data, error } = await supabase .from('profiles') @@ -226,7 +308,7 @@ export const getUserSettings = async (userId: string, client?: SupabaseClient) = .single(); if (error) throw error; return (data?.settings as any) || {}; - }); + }, 100000); }; export const updateUserSettings = async (userId: string, settings: any, client?: SupabaseClient) => { @@ -256,6 +338,75 @@ export const getUserOpenAIKey = async (userId: string, client?: SupabaseClient) }); } +export const getUserGoogleApiKey = async (userId: string, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + return fetchWithDeduplication(`google-${userId}`, async () => { + const { data, error } = await supabase + .from('user_secrets') + .select('settings') + .eq('user_id', userId) + .maybeSingle(); + + if (error) throw error; + const settings = data?.settings as any; + return settings?.api_keys?.google_api_key; + }); +} + +export const getProviderConfig = async (userId: string, provider: string, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + return fetchWithDeduplication(`provider-${userId}-${provider}`, async () => { + const { data, error } = await supabase + .from('provider_configs') + .select('settings') + .eq('user_id', userId) + .eq('name', provider) + .eq('is_active', true) + .single(); + + if (error) { + // It's common to not have configs for all providers, so we might want to suppress some errors or handle them gracefully + // However, checking error code might be robust. For now let's just throw if it's not a "no rows" error, + // or just return null if not found. + // The original code used .single() which errors if 0 rows. + // Let's use maybeSingle() to be safe? The original code caught the error and returned null. + // But the original query strictly used .single(). + // Let's stick to .single() but catch it here if we want to mimic exact behavior, OR use maybeSingle and return null. + // The calling code expects null if not found. + if (error.code === 'PGRST116') return null; // No rows found + throw error; + } + return data as { settings: any }; + }); +} + +export const fetchUserPage = async (userId: string, slug: string, client?: SupabaseClient) => { + const supabase = client || defaultSupabase; + const key = `user-page-${userId}-${slug}`; + // Cache for 10 minutes (600000ms) + return fetchWithDeduplication(key, async () => { + const { data: sessionData } = await supabase.auth.getSession(); + const token = sessionData.session?.access_token; + + const headers: HeadersInit = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`/api/user-page/${userId}/${slug}`, { headers }); + + if (!res.ok) { + if (res.status === 404) return null; + throw new Error(`Failed to fetch user page: ${res.statusText}`); + } + + return await res.json(); + }, 600000); +}; + +export const invalidateUserPageCache = (userId: string, slug: string) => { + const key = `user-page-${userId}-${slug}`; + invalidateCache(key); +}; + export const addCollectionPictures = async (inserts: { collection_id: string, picture_id: string }[], client?: SupabaseClient) => { const supabase = client || defaultSupabase; const { error } = await supabase @@ -354,20 +505,6 @@ export const fetchUserLikesForPictures = async (userId: string, pictureIds: stri }); }; -export interface FeedPost { - id: string; // Post ID - title: string; - description: string | null; - created_at: string; - user_id: string; - pictures: MediaItem[]; // All visible pictures - cover: MediaItem; // The selected cover picture - likes_count: number; - comments_count: number; - type: MediaType; - author_profile?: UserProfile; -} - export const fetchFeedPosts = async ( source: 'home' | 'collection' | 'tag' | 'user' | 'widget' = 'home', sourceId?: string, @@ -375,7 +512,6 @@ export const fetchFeedPosts = async ( orgSlug?: string, client?: SupabaseClient ): Promise => { - // Forward to paginated version with defaults return fetchFeedPostsPaginated(source, sourceId, isOrgContext, orgSlug, 0, 30, client); }; @@ -435,6 +571,25 @@ export const fetchFeedPostsPaginated = async ( const { data: postsData, error: postsError } = await query; if (postsError) throw postsError; + // Manually fetch profiles since foreign key might be missing + if (postsData && postsData.length > 0) { + const userIds = Array.from(new Set(postsData.map((p: any) => p.user_id).filter(Boolean))); + console.log('userIds', userIds); + if (userIds.length > 0) { + const { data: profiles } = await supabase + .from('profiles') + .select('user_id, username, display_name, avatar_url') + .in('user_id', userIds); + + if (profiles) { + const profileMap = new Map(profiles.map(p => [p.user_id, p])); + postsData.forEach((p: any) => { + p.author = profileMap.get(p.user_id); + }); + } + } + } + // 2. Fetch Pages (if applicable) // Only fetch pages for home/user/org sources to keep it simple for now let pagesData: any[] = []; @@ -683,6 +838,9 @@ export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | ' } if (!cover) return null; + + const versionCount = post.pictures ? post.pictures.length : 1; + return { id: post.id, picture_id: cover.id, @@ -698,12 +856,13 @@ export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | ' comments: [{ count: post.comments_count }], responsive: (cover as any).responsive, job: (cover as any).job, - // author_profile must be populated externally + author: post.author, + versionCount }; }).filter(item => item !== null); }; -// Augment posts if they come from API/Hydration (missing cover/author_profile) +// Augment posts if they come from API/Hydration (missing cover/author) export const augmentFeedPosts = (posts: any[]): FeedPost[] => { return posts.map(p => { // Check if we need to augment (heuristic: missing cover) @@ -715,12 +874,12 @@ export const augmentFeedPosts = (posts: any[]): FeedPost[] => { return { ...p, cover: validPics[0] || pics[0], // fallback to first if none visible? - author_profile: p.author ? { + author: p.author || (p.author ? { user_id: p.author.user_id, username: p.author.username, display_name: p.author.display_name, avatar_url: p.author.avatar_url - } : undefined + } : undefined) }; } return p; diff --git a/packages/ui/src/lib/openai.ts b/packages/ui/src/lib/openai.ts index bd6f81d0..7fd5affe 100644 --- a/packages/ui/src/lib/openai.ts +++ b/packages/ui/src/lib/openai.ts @@ -18,6 +18,7 @@ import { JSONSchema } from 'openai/lib/jsonschema'; import { createImage as createImageRouter, editImage as editImageRouter } from '@/lib/image-router'; import { generateTextWithImagesTool } from '@/lib/markdownImageTools'; import { createPageTool } from '@/lib/pageTools'; +import { getUserOpenAIKey } from '@/lib/db'; type LogFunction = (level: string, message: string, data?: any) => void; @@ -80,19 +81,7 @@ const getOpenAIApiKey = async (): Promise => { return null; } - const { data: secretData, error } = await supabase - .from('user_secrets') - .select('settings') - .eq('user_id', user.id) - .maybeSingle(); - - if (error) { - consoleLogger.error('Error fetching user secrets:', error); - return null; - } - - const settings = secretData?.settings as { api_keys?: Record } | null; - const apiKey = settings?.api_keys?.openai_api_key; + const apiKey = await getUserOpenAIKey(user.id); if (!apiKey) { consoleLogger.error('No OpenAI API key found in user secrets. Please add your OpenAI API key in your profile settings.'); diff --git a/packages/ui/src/lib/toc.ts b/packages/ui/src/lib/toc.ts index 36e0570e..c11070cb 100644 --- a/packages/ui/src/lib/toc.ts +++ b/packages/ui/src/lib/toc.ts @@ -68,7 +68,7 @@ export function extractHeadingsFromLayout(layout: PageLayout): MarkdownHeading[] container.children.forEach(processContainer); }; - layout.containers.forEach(processContainer); + layout.containers?.forEach(processContainer); return allHeadings; } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index ea05e58b..332496d2 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,16 +1,5 @@ import { createRoot } from "react-dom/client"; -import { registerSW } from 'virtual:pwa-register' import App from "./App.tsx"; - -// Register Service Worker -const updateSW = registerSW({ - onNeedRefresh() { - // Optionally ask user to refresh, but for now we auto-update - }, - onOfflineReady() { - console.log('PWA App is ready to work offline') - }, -}) import "./index.css"; import { ThemeProvider } from "@/components/ThemeProvider"; diff --git a/packages/ui/src/pages/PlaygroundEditorLLM.tsx b/packages/ui/src/pages/PlaygroundEditorLLM.tsx index 4db52e39..864362e2 100644 --- a/packages/ui/src/pages/PlaygroundEditorLLM.tsx +++ b/packages/ui/src/pages/PlaygroundEditorLLM.tsx @@ -16,6 +16,8 @@ import { toast } from 'sonner'; import { translate } from '@/i18n'; import OpenAI from 'openai'; import SimpleLogViewer from '@/components/SimpleLogViewer'; +import { getUserSettings, updateUserSettings, getProviderConfig } from '@/lib/db'; +import { usePromptHistory } from '@/hooks/usePromptHistory'; const PlaygroundEditorLLM: React.FC = () => { const { user } = useAuth(); @@ -23,16 +25,26 @@ const PlaygroundEditorLLM: React.FC = () => { const [selectedText, setSelectedText] = useState(''); // AI Text Generator state - const [prompt, setPrompt] = useState(''); - const [promptHistory, setPromptHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); + // AI Text Generator state + const { + prompt, + setPrompt, + promptHistory, + historyIndex, + setHistoryIndex, + navigateHistory, + addPromptToHistory + } = usePromptHistory(); + const [selectedProvider, setSelectedProvider] = useState('openai'); const [selectedModel, setSelectedModel] = useState('gpt-5'); const [imageToolsEnabled, setImageToolsEnabled] = useState(false); + const [webSearchEnabled, setWebSearchEnabled] = useState(false); const [contextMode, setContextMode] = useState<'clear' | 'selection' | 'all'>('all'); const [applicationMode, setApplicationMode] = useState<'replace' | 'insert' | 'append'>('append'); const [templates, setTemplates] = useState>([]); const [streamMode, setStreamMode] = useState(false); + const [settingsLoaded, setSettingsLoaded] = useState(false); // Generation state const [isGenerating, setIsGenerating] = useState(false); @@ -48,6 +60,70 @@ const PlaygroundEditorLLM: React.FC = () => { const lastSelectionRef = useRef<{ isAtStart: boolean } | null>(null); const insertTransactionRef = useRef<{ text: string, onComplete: () => void } | null>(null); + // Load settings + useEffect(() => { + const loadSettings = async () => { + if (!user) { + setSettingsLoaded(true); + return; + } + + try { + const settings = await getUserSettings(user.id); + const aiTextSettings = settings?.aiTextGenerator; + const savedTemplates = settings?.promptTemplates || []; + + if (aiTextSettings) { + if (aiTextSettings.provider) setSelectedProvider(aiTextSettings.provider); + if (aiTextSettings.model) setSelectedModel(aiTextSettings.model); + if (typeof aiTextSettings.imageToolsEnabled === 'boolean') setImageToolsEnabled(aiTextSettings.imageToolsEnabled); + if (aiTextSettings.contextMode) setContextMode(aiTextSettings.contextMode); + if (aiTextSettings.applicationMode) setApplicationMode(aiTextSettings.applicationMode); + } + + if (savedTemplates.length > 0) { + setTemplates(savedTemplates); + } + } catch (error) { + console.error('Error loading settings:', error); + } finally { + setSettingsLoaded(true); + } + }; + + loadSettings(); + }, [user]); + + // Save settings (debounced) + useEffect(() => { + if (!user || !settingsLoaded) return; + + const saveSettings = async () => { + try { + const currentSettings = await getUserSettings(user.id); + + const updatedSettings = { + ...currentSettings, + aiTextGenerator: { + provider: selectedProvider, + model: selectedModel, + imageToolsEnabled, + // webSearchEnabled: false, // Not locally managed yet + contextMode, + applicationMode, + } + }; + + await updateUserSettings(user.id, updatedSettings); + } catch (error) { + console.error('Error saving settings:', error); + } + }; + + const timeoutId = setTimeout(saveSettings, 1000); + return () => clearTimeout(timeoutId); + }, [user, settingsLoaded, selectedProvider, selectedModel, imageToolsEnabled, contextMode, applicationMode]); + const handleContentChange = useCallback((newContent: string) => { setContent(newContent); @@ -81,29 +157,7 @@ const PlaygroundEditorLLM: React.FC = () => { }, []); - const addToHistory = useCallback((text: string) => { - if (text.trim()) { - setPromptHistory(prev => [text, ...prev.slice(0, 49)]); - setHistoryIndex(-1); - } - }, []); - - const navigateHistory = useCallback((direction: 'up' | 'down') => { - if (direction === 'up' && historyIndex < promptHistory.length - 1) { - const newIndex = historyIndex + 1; - setHistoryIndex(newIndex); - setPrompt(promptHistory[newIndex]); - } else if (direction === 'down') { - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - setHistoryIndex(newIndex); - setPrompt(promptHistory[newIndex]); - } else { - setHistoryIndex(-1); - setPrompt(''); - } - } - }, [historyIndex, promptHistory]); + // History handlers replaced by hook const getProviderApiKey = useCallback(async (provider: string): Promise => { if (!user) return null; @@ -121,15 +175,9 @@ const PlaygroundEditorLLM: React.FC = () => { } try { - const { data: userProvider, error } = await supabase - .from('provider_configs') - .select('settings') - .eq('user_id', user.id) - .eq('name', provider) - .eq('is_active', true) - .single(); + const userProvider = await getProviderConfig(user.id, provider); - if (error || !userProvider) { + if (!userProvider) { if (provider !== 'openai') { // console.warn(\`No provider configuration found for \${provider}\`); } @@ -240,7 +288,7 @@ const PlaygroundEditorLLM: React.FC = () => { abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; setIsGenerating(true); - addToHistory(prompt); + addPromptToHistory(prompt); try { // Build context prompt @@ -384,7 +432,7 @@ const PlaygroundEditorLLM: React.FC = () => { } }, [ prompt, user, applicationMode, contextMode, selectedText, content, imageToolsEnabled, - selectedProvider, selectedModel, addToHistory, generateWithProvider, getProviderApiKey, streamMode + selectedProvider, selectedModel, addPromptToHistory, generateWithProvider, getProviderApiKey, streamMode ]); const handleOptimize = useCallback(async () => { @@ -472,7 +520,7 @@ const PlaygroundEditorLLM: React.FC = () => { } }, [isRecording]); - const handleSaveTemplate = useCallback(() => { + const handleSaveTemplate = useCallback(async () => { if (!prompt.trim()) { toast.error('Please enter a prompt to save'); return; @@ -480,15 +528,43 @@ const PlaygroundEditorLLM: React.FC = () => { const name = window.prompt('Enter template name:'); if (name) { - setTemplates(prev => [...prev, { name, template: prompt }]); + const newTemplates = [...templates, { name, template: prompt }]; + setTemplates(newTemplates); toast.success('Template saved!'); - } - }, [prompt]); - const handleDeleteTemplate = useCallback((index: number) => { - setTemplates(prev => prev.filter((_, i) => i !== index)); + // Save to server + if (user) { + try { + const currentSettings = await getUserSettings(user.id); + await updateUserSettings(user.id, { + ...currentSettings, + promptTemplates: newTemplates + }); + } catch (err) { + console.error('Error saving templates', err); + } + } + } + }, [prompt, templates, user]); + + const handleDeleteTemplate = useCallback(async (index: number) => { + const newTemplates = templates.filter((_, i) => i !== index); + setTemplates(newTemplates); toast.success('Template deleted'); - }, []); + + // Save to server + if (user) { + try { + const currentSettings = await getUserSettings(user.id); + await updateUserSettings(user.id, { + ...currentSettings, + promptTemplates: newTemplates + }); + } catch (err) { + console.error('Error saving templates', err); + } + } + }, [templates, user]); const handleApplyTemplate = useCallback((template: string) => { setPrompt(template); @@ -539,6 +615,8 @@ const PlaygroundEditorLLM: React.FC = () => { onModelChange={setSelectedModel} imageToolsEnabled={imageToolsEnabled} onImageToolsChange={setImageToolsEnabled} + webSearchEnabled={webSearchEnabled} + onWebSearchChange={setWebSearchEnabled} contextMode={contextMode} onContextModeChange={setContextMode} hasSelection={!!selectedText && selectedText.trim().length > 0} diff --git a/packages/ui/src/pages/Post.tsx b/packages/ui/src/pages/Post.tsx index 8fe3a09b..c44fde3e 100644 --- a/packages/ui/src/pages/Post.tsx +++ b/packages/ui/src/pages/Post.tsx @@ -18,6 +18,7 @@ import { CompactRenderer } from "./Post/renderers/CompactRenderer"; import { usePostActions } from "./Post/usePostActions"; import { exportMarkdown, downloadMediaItem } from "./Post/PostActions"; import { DeleteDialog } from "./Post/components/DeleteDialogs"; + import '@vidstack/react/player/styles/default/theme.css'; import '@vidstack/react/player/styles/default/layouts/video.css'; @@ -426,9 +427,17 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) => useEffect(() => { if (mediaItem) { - loadVersions(); - fetchAuthorProfile(); - checkIfLiked(mediaItem.id); + // loadVersions(); // Deprecated: Versions handled by server aggregation + // fetchAuthorProfile(); // Deprecated: Author returned in post details + // checkIfLiked(mediaItem.id); // Deprecated: is_liked returned in post details + + // We still update local like state when mediaItem changes + if (mediaItem.is_liked !== undefined) { + setIsLiked(mediaItem.is_liked || false); + } + if (mediaItem.likes_count !== undefined) { + setLikesCount(mediaItem.likes_count); + } } }, [mediaItem, user]); diff --git a/packages/ui/src/pages/Post/db.ts b/packages/ui/src/pages/Post/db.ts index 4521e55a..82def225 100644 --- a/packages/ui/src/pages/Post/db.ts +++ b/packages/ui/src/pages/Post/db.ts @@ -1 +1,33 @@ export * from '@/lib/db'; + +export const fetchPostDetailsAPI = async (id: string, options: { sizes?: string, formats?: string } = {}) => { + const params = new URLSearchParams(); + if (options.sizes) params.set('sizes', options.sizes); + if (options.formats) params.set('formats', options.formats); + + const qs = params.toString(); + const url = `/api/posts/${id}${qs ? `?${qs}` : ''}`; + + // We rely on the browser/hook to handle auth headers if global fetch is intercepted, + // OR we explicitly get session? + // Usually standard `fetch` in our app might not send auth if using implicit flows or we need to pass headers. + // In `useFeedData`, we manually added headers. + // Let's assume we need to handle auth here or use a helper that does. + // To keep it simple for now, we'll import `supabase` and get session. + + const { supabase } = await import('@/integrations/supabase/client'); + const { data: { session } } = await supabase.auth.getSession(); + + const headers: Record = {}; + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + const res = await fetch(url, { headers }); + if (!res.ok) { + if (res.status === 404) return null; + throw new Error(`Failed to fetch post: ${res.statusText}`); + } + + return res.json(); +}; diff --git a/packages/ui/src/pages/UserPage.tsx b/packages/ui/src/pages/UserPage.tsx index 54bab8aa..011d429c 100644 --- a/packages/ui/src/pages/UserPage.tsx +++ b/packages/ui/src/pages/UserPage.tsx @@ -6,7 +6,6 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ArrowLeft, FileText, Calendar, Eye, EyeOff, Edit, Edit3, Check, X, Plus, PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { ThemeToggle } from "@/components/ThemeToggle"; import { T, translate } from "@/i18n"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -19,6 +18,7 @@ import { TableOfContents } from "@/components/sidebar/TableOfContents"; import { MobileTOC } from "@/components/sidebar/MobileTOC"; import { extractHeadings, extractHeadingsFromLayout, MarkdownHeading } from "@/lib/toc"; import { useLayout } from "@/contexts/LayoutContext"; +import { fetchUserPage, invalidateUserPageCache } from "@/lib/db"; interface Page { id: string; @@ -54,12 +54,15 @@ interface UserPageProps { } const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initialPage }: UserPageProps) => { - const { userId: paramUserId, slug: paramSlug, orgSlug } = useParams<{ userId: string; slug: string; orgSlug?: string }>(); + const { userId: paramUserId, username: paramUsername, slug: paramSlug, orgSlug } = useParams<{ userId: string; username: string; slug: string; orgSlug?: string }>(); const navigate = useNavigate(); const { user: currentUser } = useAuth(); const { getLoadedPageLayout, loadPageLayout } = useLayout(); - const userId = propUserId || paramUserId; + const [resolvedUserId, setResolvedUserId] = useState(null); + + // Determine effective userId - either from prop, existing param, or resolved from username + const userId = propUserId || paramUserId || resolvedUserId; const slug = propSlug || paramSlug; const [page, setPage] = useState(initialPage || null); @@ -90,94 +93,45 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia setLoading(false); return; } - if (userId && slug) { - fetchPage(); - fetchUserProfile(); + + // Determine effective identifier + const identifier = propUserId || paramUserId || paramUsername; + const effectiveSlug = propSlug || paramSlug; + + if (identifier && effectiveSlug) { + fetchUserPageData(identifier, effectiveSlug); } - }, [userId, slug, initialPage]); + }, [propUserId, paramUserId, paramUsername, propSlug, paramSlug, initialPage]); - const fetchChildPages = async (parentId: string) => { + const fetchUserPageData = async (id: string, slugStr: string) => { + setLoading(true); try { - let query = supabase - .from('pages') - .select('id, title, slug, visible, is_public') - .eq('parent', parentId) - .order('title'); + const data = await fetchUserPage(id, slugStr); - if (!isOwner) { - query = query.eq('visible', true).eq('is_public', true); - } + if (data) { + setPage(data.page); + setUserProfile(data.userProfile as any); // Cast to match local interface if needed + setChildPages(data.childPages || []); - const { data, error } = await query; - - if (error) throw error; - setChildPages(data || []); - } catch (error) { - console.error('Error fetching child pages:', error); - } - }; - - const fetchPage = async () => { - try { - setLoading(true); - - let query = supabase - .from('pages') - .select('*') - .eq('slug', slug) - .eq('owner', userId); - - // If not owner, only show public and visible pages - if (!isOwner) { - query = query.eq('is_public', true).eq('visible', true); - } - - const { data, error } = await query.maybeSingle(); - - if (error) throw error; - - if (!data) { + // If we resolved via username, ensure we have the userId for isOwner check + if (!resolvedUserId && data.page.owner) { + setResolvedUserId(data.page.owner); + } + } else { toast.error(translate('Page not found or you do not have access')); - navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`); - return; + navigate(orgSlug ? `/org/${orgSlug}/user/${id}` : `/user/${id}`); } - - setPage(data as Page); - - // Fetch parent page if it exists - if (data.parent) { - fetchParentPage(data.parent); - } - - // Fetch child pages - fetchChildPages(data.id); } catch (error) { - console.error('Error fetching page:', error); + console.error('Error fetching user page:', error); toast.error(translate('Failed to load page')); - navigate(orgSlug ? `/org/${orgSlug}/user/${userId}` : `/user/${userId}`); } finally { setLoading(false); } }; - const fetchParentPage = async (parentId: string) => { - try { - const { data, error } = await supabase - .from('pages') - .select('title, slug') - .eq('id', parentId) - .single(); - if (error) throw error; - setPage(prev => prev ? ({ - ...prev, - parent_page: data - }) : null); - } catch (error) { - console.error('Error fetching parent page:', error); - } - }; + // Reactive Heading Extraction // This ensures we extract headings whenever the page loads OR when specific layouts are loaded into context @@ -226,43 +180,20 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia } }, [headings]); // Run when headings are populated - const fetchUserProfile = async () => { - try { - const { data: profile } = await supabase - .from('profiles') - .select('user_id, username, display_name, avatar_url') - .eq('user_id', userId) - .maybeSingle(); - if (profile) { - setUserProfile({ - id: profile.user_id, - username: profile.username, - display_name: profile.display_name, - avatar_url: profile.avatar_url, - }); - } - } catch (error) { - console.error('Error fetching user profile:', error); - } - }; // Actions now handled by PageActions component const handlePageUpdate = (updatedPage: Page) => { - // Check if parent has changed + // If parent changed or critical metadata changed, strictly we should re-fetch to get enriched data + // But for responsiveness we can update local state. + // If parent changed, we don't have the new parent's title/slug unless we fetch it. + // So if parent ID changed, we should probably re-fetch the whole page data from server. + if (updatedPage.parent !== page?.parent) { - if (!updatedPage.parent) { - // Parent removed - setPage({ ...updatedPage, parent_page: null }); - } else { - // Parent changed, fetch new details - setPage(updatedPage); - fetchParentPage(updatedPage.parent); + // Re-fetch everything to get correct parent details + if (userId && updatedPage.slug) { + fetchUserPageData(userId, updatedPage.slug); } - // Also refresh children if needed, though usually this affects *other* pages listing this one as child - // But if we became a child, we might want to check something. - // Actually, if we are viewing this page, its children list shouldn't change just by changing its parent, - // unless we selected one of our children as parent (which is forbidden by picker). } else { setPage(updatedPage); } @@ -305,6 +236,8 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia setPage({ ...page, title: titleValue.trim() }); setEditingTitle(false); + // Invalidate cache for this page + if (userId && page.slug) invalidateUserPageCache(userId, page.slug); toast.success(translate('Title updated')); } catch (error) { console.error('Error updating title:', error); @@ -360,6 +293,8 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia console.error('Error updating slug:', error); toast.error(translate('Failed to update slug')); } finally { + if (userId && page?.slug) invalidateUserPageCache(userId, page.slug); // Invalidate old slug + if (userId) invalidateUserPageCache(userId, slugValue.trim()); // Invalidate new slug to be safe setSavingField(null); } }; @@ -390,6 +325,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia console.error('Error updating tags:', error); toast.error(translate('Failed to update tags')); } finally { + if (userId && page?.slug) invalidateUserPageCache(userId, page.slug); setSavingField(null); } }; @@ -709,6 +645,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia pageName={page.title} isEditMode={isEditMode && isOwner} showControls={isEditMode && isOwner} + initialLayout={page.content} /> )}
diff --git a/packages/ui/src/pages/UserProfile.tsx b/packages/ui/src/pages/UserProfile.tsx index 73b7ae13..f6afa6cb 100644 --- a/packages/ui/src/pages/UserProfile.tsx +++ b/packages/ui/src/pages/UserProfile.tsx @@ -11,6 +11,8 @@ import { ThemeToggle } from "@/components/ThemeToggle"; import UserPictures from "@/components/UserPictures"; import { T, translate } from "@/i18n"; import { normalizeMediaType } from "@/lib/mediaRegistry"; +import { useFeedData } from "@/hooks/useFeedData"; +import * as db from "@/lib/db"; interface UserProfile { id: string; @@ -42,6 +44,7 @@ const UserProfile = () => { const navigate = useNavigate(); const { user: currentUser } = useAuth(); const [userProfile, setUserProfile] = useState(null); + console.log(userProfile, "userProfile"); // Post states const [publicPosts, setPublicPosts] = useState([]); @@ -49,25 +52,69 @@ const UserProfile = () => { const [collections, setCollections] = useState([]); const [organizations, setOrganizations] = useState([]); - const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState<'posts' | 'hidden' | 'pictures'>('posts'); const isOwnProfile = currentUser?.id === userId; + // Post states handled by useFeedData + const { posts: feedPosts, loading: feedLoading } = useFeedData({ + source: 'user', + sourceId: userId, + enabled: !!userId, + sortBy: 'latest' + }); + + const [stats, setStats] = useState({ public: 0, hidden: 0, total: 0 }); + useEffect(() => { if (userId) { fetchUserProfile(); - fetchUserPosts(); fetchUserCollections(); - fetchUserOrganizations(); + fetchUserStats(); } - }, [userId, currentUser]); + }, [userId]); // Removed userProfile and currentUser from deps to prevent loop + + useEffect(() => { + if (feedPosts) { + // Separate into Public/Listed vs Private (Hidden) + // Note: feedPosts are already FeedPost objects, map them or use as is? + // PhotoGrid expects MediaItemType via mapFeedPostsToMediaItems, but it handles FeedPost[] via customPictures? + // Actually customPictures expects MediaItemType[]. + // We need to map them. + const mediaItems = db.mapFeedPostsToMediaItems(feedPosts, 'latest'); + + const publicAndListed = mediaItems.filter((p: any) => !p.meta?.visibility || p.meta?.visibility !== 'private'); + // Need to check where visibility is stored. FeedPost has settings. + // mapFeedPostsToMediaItems might lose settings? + // Let's check mapFeedPostsToMediaItems. It maps 'meta' from 'cover.meta'. + // Visibility is usually on the post 'settings'. mapFeedPostsToMediaItems doesn't seem to pass 'settings' or 'visibility' explicitly? + // We might need to filter on feedPosts first then map. + + const publicFeed = feedPosts.filter(p => !p.settings?.visibility || p.settings.visibility !== 'private'); + const hiddenFeed = feedPosts.filter(p => p.settings?.visibility === 'private'); + + setPublicPosts(db.mapFeedPostsToMediaItems(publicFeed, 'latest')); + setHiddenPosts(db.mapFeedPostsToMediaItems(hiddenFeed, 'latest')); + } + }, [feedPosts]); const fetchUserProfile = async () => { + console.log('fetchUserProfile', userId); try { const { data: profile, error: profileError } = await supabase .from('profiles') - .select('*') + .select(` + *, + user_roles (role), + user_organizations ( + role, + organizations ( + id, + name, + slug + ) + ) + `) .eq('user_id', userId) .maybeSingle(); @@ -75,25 +122,44 @@ const UserProfile = () => { throw profileError; } - if (!profile) { - setUserProfile({ - id: userId!, - username: null, - display_name: `User ${userId!.slice(0, 8)}`, - bio: null, - avatar_url: null, - created_at: new Date().toISOString(), - }); + const newProfile = profile ? { + 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: userId!, + username: null, + display_name: `User ${userId!.slice(0, 8)}`, + bio: null, + avatar_url: null, + created_at: new Date().toISOString(), + }; + + setUserProfile(prev => JSON.stringify(prev) !== JSON.stringify(newProfile) ? newProfile : prev); + + // Process Orgs and Roles from the profile fetch + if (profile) { + // Process Organizations + const orgsData = (profile as any).user_organizations || []; + const orgs = orgsData.map((item: any) => ({ + id: item.organizations?.id, + name: item.organizations?.name, + slug: item.organizations?.slug, + role: item.role + })).filter((o: any) => o.id); // Filter out invalid + + setOrganizations(orgs); + + // Process Roles (if we want to store them, user didn't ask for UI but asked for resolving) + // const roles = (profile as any).user_roles?.map((r: any) => r.role) || []; + // setRoles(roles); // If we had a roles state } 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, - }); + setOrganizations([]); } + } catch (error) { console.error('Error fetching user profile:', error); toast.error(translate('Failed to load user profile')); @@ -101,6 +167,30 @@ const UserProfile = () => { } }; + const fetchUserStats = async () => { + try { + const { count: publicCount } = await supabase + .from('posts') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .or('settings.is.null,settings->>visibility.eq.public'); + + const { count: hiddenCount } = await supabase + .from('posts') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('settings->>visibility', 'private'); + + setStats({ + public: publicCount || 0, + hidden: hiddenCount || 0, + total: (publicCount || 0) + (hiddenCount || 0) + }); + } catch (e) { + console.error("Error fetching stats", e); + } + }; + const fetchUserCollections = async () => { try { const { data, error } = await supabase @@ -116,106 +206,9 @@ const UserProfile = () => { } }; - const fetchUserOrganizations = async () => { - try { - const { data, error } = await supabase - .from('user_organizations') - .select(` - role, - organizations ( - id, - name, - slug - ) - `) - .eq('user_id', userId); - if (error) throw error; - const orgs = data?.map(item => ({ - id: (item.organizations as any).id, - name: (item.organizations as any).name, - slug: (item.organizations as any).slug, - role: item.role - })) || []; - setOrganizations(orgs); - } catch (error) { - console.error('Error fetching user organizations:', error); - } - }; - - const fetchUserPosts = async () => { - try { - setLoading(true); - - let query = supabase - .from('posts') - .select(` - *, - pictures ( - id, - image_url, - thumbnail_url, - type, - meta, - description, - likes_count, - visible, - is_selected - ) - `) - .eq('user_id', userId) - .order('created_at', { ascending: false }); - - const { data: posts, error } = await query; - - if (error) throw error; - - // Transform posts for Grid - const transformedPosts = (posts || []).map((post: any) => { - // Find main picture (first one) - const mainPicture = post.pictures?.[0]; - - return { - id: post.id, - title: post.title, - description: post.description, - // Use thumbnail_url if available (videos), else image_url - image_url: mainPicture?.thumbnail_url || mainPicture?.image_url || '', - thumbnail_url: mainPicture?.thumbnail_url || mainPicture?.image_url || null, - type: normalizeMediaType(mainPicture?.type), - meta: mainPicture?.meta, - likes_count: 0, // aggregate likes count if needed, or just 0 for now - created_at: post.created_at, - user_id: post.user_id, - comments: [{ count: 0 }], // placeholder - settings: post.settings - }; - }).filter(p => p.image_url); // Filter out posts without media for now - - // Separate into Public/Listed vs Private (Hidden) - const publicAndListed = transformedPosts.filter(p => !p.settings?.visibility || p.settings.visibility !== 'private'); - const privateHidden = transformedPosts.filter(p => p.settings?.visibility === 'private'); - - setPublicPosts(publicAndListed); - setHiddenPosts(privateHidden); - - } catch (error) { - console.error('Error fetching user posts:', error); - toast.error(translate('Failed to load user posts')); - } finally { - setLoading(false); - } - }; - - if (loading) { - return ( -
-
Loading profile...
-
- ); - } if (!userProfile) { return ( @@ -299,7 +292,7 @@ const UserProfile = () => { {/* Stats */}
-
{publicPosts.length + (isOwnProfile ? hiddenPosts.length : 0)}
+
{stats.total}
posts
@@ -333,7 +326,7 @@ const UserProfile = () => {
{/* View All Collections Link */}
@@ -361,7 +354,7 @@ const UserProfile = () => { {collections.slice(0, 3).map((collection) => (
@@ -417,7 +410,7 @@ const UserProfile = () => { {/* Pages Section */}
- +
{/* Tab Navigation */} @@ -472,12 +465,12 @@ const UserProfile = () => {
- {isOwnProfile && activeTab === 'posts' && publicPosts.length === 0 && !loading && ( + {isOwnProfile && activeTab === 'posts' && publicPosts.length === 0 && !feedLoading && (