supabase fuck 2/5

This commit is contained in:
lovebird 2026-02-02 00:12:16 +01:00
parent ba9fba49cb
commit 4c734d4d3c
30 changed files with 785 additions and 758 deletions

View File

@ -77,7 +77,7 @@ const AppWrapper = () => {
<Route path="/user/:userId" element={<UserProfile />} />
<Route path="/user/:userId/collections" element={<UserCollections />} />
<Route path="/user/:userId/pages/new" element={<NewPage />} />
<Route path="/user/:userId/pages/:slug" element={<UserPage />} />
<Route path="/user/:username/pages/:slug" element={<UserPage />} />
<Route path="/collections/new" element={<NewCollection />} />
<Route path="/collections/:userId/:slug" element={<Collections />} />
<Route path="/tags/:tag" element={<TagPage />} />
@ -109,7 +109,7 @@ const AppWrapper = () => {
<Route path="/org/:orgSlug/user/:userId" element={<UserProfile />} />
<Route path="/org/:orgSlug/user/:userId/collections" element={<UserCollections />} />
<Route path="/org/:orgSlug/user/:userId/pages/new" element={<NewPage />} />
<Route path="/org/:orgSlug/user/:userId/pages/:slug" element={<UserPage />} />
<Route path="/org/:orgSlug/user/:username/pages/:slug" element={<UserPage />} />
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />

View File

@ -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<MediaItemType[]>([]);
const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
const [userLikes, setUserLikes] = useState<Set<string>>(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;

View File

@ -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<React.SetStateAction<Array<{name: string; template: string}>>>,
setPromptTemplates: React.Dispatch<React.SetStateAction<Array<{ name: string; template: string }>>>,
setLoadingTemplates: React.Dispatch<React.SetStateAction<boolean>>
) => {
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<React.SetStateAction<string[]>>
) => {
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<React.SetStateAction<number>>
) => {
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<React.SetStateAction<string>>
) => {
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<React.SetStateAction<PromptPreset[]>>
) => {
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<React.SetStateAction<PromptPreset[]>>
) => {
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<React.SetStateAction<PromptPreset[]>>
) => {
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<React.SetStateAction<Workflow[]>>
) => {
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<React.SetStateAction<Workflow[]>>
) => {
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<React.SetStateAction<Workflow[]>>
) => {
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!'));

View File

@ -34,6 +34,7 @@ interface MediaCardProps {
job?: any;
variant?: 'grid' | 'feed';
apiUrl?: string;
versionCount?: number;
}
const MediaCard: React.FC<MediaCardProps> = ({
@ -60,7 +61,8 @@ const MediaCard: React.FC<MediaCardProps> = ({
responsive,
job,
variant = 'grid',
apiUrl
apiUrl,
versionCount
}) => {
const normalizedType = normalizeMediaType(type);
// Render based on type
@ -151,6 +153,7 @@ const MediaCard: React.FC<MediaCardProps> = ({
responsive={responsive}
variant={variant}
apiUrl={apiUrl}
versionCount={versionCount}
/>
);
};

View File

@ -14,6 +14,7 @@ interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
authorAvatarUrl?: string | null;
created_at?: string;
apiUrl?: string;
versionCount?: number;
}
const PageCard: React.FC<PageCardProps> = ({
@ -34,11 +35,13 @@ const PageCard: React.FC<PageCardProps> = ({
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<PageCardProps> = ({
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*).

View File

@ -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<Page[]>([]);
@ -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) => {
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"><T>Pages</T></h3>
{isOwnProfile && (
<Button
size="sm"
<Button
size="sm"
variant="outline"
onClick={() => {
const newPageUrl = orgSlug
? `/org/${orgSlug}/user/${userId}/pages/new`
: `/user/${userId}/pages/new`;
const newPageUrl = orgSlug
? `/org/${orgSlug}/user/${username || userId}/pages/new`
: `/user/${username || userId}/pages/new`;
navigate(newPageUrl);
}}
>
@ -207,7 +208,7 @@ const PageManager = ({ userId, isOwnProfile, orgSlug }: PageManagerProps) => {
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-base">
<Link
<Link
to={getPageUrl(page.slug)}
className="hover:text-primary transition-colors"
>

View File

@ -38,6 +38,7 @@ interface PhotoCardProps {
responsive?: any;
variant?: 'grid' | 'feed';
apiUrl?: string;
versionCount?: number;
}
const PhotoCard = ({
@ -60,7 +61,8 @@ const PhotoCard = ({
showContent = true,
responsive,
variant = 'grid',
apiUrl
apiUrl,
versionCount
}: PhotoCardProps) => {
const { user } = useAuth();
const navigate = useNavigate();
@ -72,48 +74,28 @@ const PhotoCard = ({
const [showLightbox, setShowLightbox] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [versionCount, setVersionCount] = useState<number>(0);
const isOwner = user?.id === authorId;
// Fetch version count for owners only
const [localVersionCount, setLocalVersionCount] = useState<number>(versionCount || 0);
// Sync prop to state if needed, or just use prop.
// If we want to allow local updates (e.g. after adding a version), we can keep state.
useEffect(() => {
const fetchVersionCount = async () => {
if (!isOwner || !user) return;
if (versionCount !== undefined) {
setLocalVersionCount(versionCount);
}
}, [versionCount]);
try {
// Count pictures that have this picture as parent OR pictures that share the same parent
const { data: currentPicture } = await supabase
.from('pictures')
.select('parent_id')
.eq('id', pictureId)
.single();
if (!currentPicture) return;
let query = supabase
.from('pictures')
.select('id', { count: 'exact', head: true });
if (currentPicture.parent_id) {
// This is a version - count all versions with same parent_id + the parent itself
query = query.or(`parent_id.eq.${currentPicture.parent_id},id.eq.${currentPicture.parent_id}`);
} else {
// This is the original - count this picture + all its versions
query = query.or(`parent_id.eq.${pictureId},id.eq.${pictureId}`);
}
const { count } = await query;
// console.log('Version count:', count);
setVersionCount(count || 1);
} catch (error) {
console.error('Error fetching version count:', error);
}
};
fetchVersionCount();
// Legacy fetch removed in favor of passed prop
/*
useEffect(() => {
// ... legacy fetch logic ...
}, [pictureId, isOwner, user]);
*/
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation();
@ -488,10 +470,10 @@ const PhotoCard = ({
<Trash2 className="h-4 w-4" />
</Button>
{versionCount > 1 && (
{localVersionCount > 1 && (
<div className="flex items-center ml-2 px-2 py-1 bg-white/20 rounded text-white text-xs">
<Layers className="h-3 w-3 mr-1" />
{versionCount}
{localVersionCount}
</div>
)}
</>

View File

@ -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 (
<div className="w-full relative">
{hasItems && isOwner && onFilesDrop && navigationSource === 'collection' && (

View File

@ -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 && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/user/${username}`)}
className="h-8 w-8 p-0"
title={translate("My Profile")}
>
<Grid3x3 className="h-4 w-4" />
</Button>
)
}
// ...
<DropdownMenuItem asChild>
<Link to={`/user/${username}`} className="flex items-center">
<User className="mr-2 h-4 w-4" />
<T>Profile</T>
</Link>
</DropdownMenuItem>
const handleLanguageChange = (langCode: string) => {
setLanguage(langCode as any);
};

View File

@ -50,7 +50,13 @@ const UserAvatarBlock: React.FC<UserAvatarBlockProps> = ({
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

View File

@ -24,17 +24,20 @@ export const FeedCard: React.FC<FeedCardProps> = ({
onNavigate
}) => {
const navigate = useNavigate();
const [isLiked, setIsLiked] = useState<boolean>(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<boolean>(initialLiked);
const [likeCount, setLikeCount] = useState(post.likes_count || 0);
const [lastTap, setLastTap] = useState<number>(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<FeedCardProps> = ({
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<FeedCardProps> = ({
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}
/>

View File

@ -15,6 +15,7 @@ interface GenericCanvasProps {
className?: string;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
initialLayout?: any;
}
const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
@ -24,11 +25,13 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
showControls = true,
className = '',
selectedWidgetId,
onSelectWidget
onSelectWidget,
initialLayout
}) => {
const {
loadedPages,
loadPageLayout,
hydratePageLayout,
addWidgetToPage,
removeWidgetFromPage,
moveWidgetInPage,
@ -45,11 +48,17 @@ const GenericCanvasComponent: React.FC<GenericCanvasProps> = ({
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<string | null>(null);
const [showWidgetPalette, setShowWidgetPalette] = useState(false);

View File

@ -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<MarkdownTextWidgetProps> = ({
// 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<MarkdownTextWidgetProps> = ({
};
loadSettings();
}, [user]);
}, [user, isEditMode]);
// Save AI Text Generator settings to profile (debounced)
useEffect(() => {
@ -133,13 +127,7 @@ const MarkdownTextWidget: React.FC<MarkdownTextWidgetProps> = ({
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<MarkdownTextWidgetProps> = ({
}
};
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<MarkdownTextWidgetProps> = ({
}
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}`);

View File

@ -21,6 +21,7 @@ interface LayoutContextType {
renameWidget: (pageId: string, widgetInstanceId: string, newId: string) => Promise<boolean>;
exportPageLayout: (pageId: string) => Promise<string>;
importPageLayout: (pageId: string, jsonData: string) => Promise<PageLayout>;
hydratePageLayout: (pageId: string, layout: PageLayout) => void;
// Manual save
saveToApi: () => Promise<boolean>;
@ -383,6 +384,14 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ 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<boolean> => {
try {
// Save all loaded pages to database
@ -417,6 +426,7 @@ export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
updateWidgetProps,
exportPageLayout,
importPageLayout,
hydratePageLayout,
saveToApi,
isLoading,
loadedPages,

View File

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

View File

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

View File

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

View File

@ -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<string | null> => {
try {
@ -44,19 +46,7 @@ export const getGoogleApiKey = async (): Promise<string | null> => {
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<string, string> } | 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.');

View File

@ -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<string, Promise<any>>();
const fetchWithDeduplication = async <T>(key: string, fetcher: () => Promise<T>): Promise<T> => {
type CacheStorageType = 'memory' | 'local';
interface StoredCacheItem<T> {
value: T;
timestamp: number;
timeout: number;
}
const fetchWithDeduplication = async <T>(
key: string,
fetcher: () => Promise<T>,
timeout: number = 25000,
storage: CacheStorageType = 'local'
): Promise<T> => {
// 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<T> = 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<T> = {
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<T>;
};
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<PostMediaItem>[], 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<FeedPost[]> => {
// 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;

View File

@ -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<string | null> => {
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<string, string> } | 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.');

View File

@ -68,7 +68,7 @@ export function extractHeadingsFromLayout(layout: PageLayout): MarkdownHeading[]
container.children.forEach(processContainer);
};
layout.containers.forEach(processContainer);
layout.containers?.forEach(processContainer);
return allHeadings;
}

View File

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

View File

@ -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<string[]>([]);
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<Array<{ name: string; template: string }>>([]);
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<string | null> => {
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}

View File

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

View File

@ -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<string, string> = {};
if (session?.access_token) {
headers['Authorization'] = `Bearer ${session.access_token}`;
}
const res = await fetch(url, { headers });
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch post: ${res.statusText}`);
}
return res.json();
};

View File

@ -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<string | null>(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<Page | null>(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}
/>
)}
</div>

View File

@ -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<UserProfile | null>(null);
console.log(userProfile, "userProfile");
// Post states
const [publicPosts, setPublicPosts] = useState<any[]>([]);
@ -49,25 +52,69 @@ const UserProfile = () => {
const [collections, setCollections] = useState<Collection[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
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 (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground"><T>Loading profile...</T></div>
</div>
);
}
if (!userProfile) {
return (
@ -299,7 +292,7 @@ const UserProfile = () => {
{/* Stats */}
<div className="flex justify-center md:justify-start gap-8 mb-6">
<div className="text-center">
<div className="font-semibold text-lg">{publicPosts.length + (isOwnProfile ? hiddenPosts.length : 0)}</div>
<div className="font-semibold text-lg">{stats.total}</div>
<div className="text-sm text-muted-foreground"><T>posts</T></div>
</div>
<div className="text-center">
@ -333,7 +326,7 @@ const UserProfile = () => {
<div className="flex gap-4 overflow-x-auto pb-2">
{/* View All Collections Link */}
<Link
to={`/user/${userId}/collections`}
to={`/user/${userProfile.username || userId}/collections`}
className="flex flex-col items-center gap-2 min-w-0 group"
>
<div className="w-16 h-16 rounded-full border-2 border-primary bg-gradient-primary flex items-center justify-center group-hover:scale-105 transition-transform">
@ -361,7 +354,7 @@ const UserProfile = () => {
{collections.slice(0, 3).map((collection) => (
<Link
key={collection.id}
to={`/collections/${userId}/${collection.slug}`}
to={`/collections/${userProfile.username || userId}/${collection.slug}`}
className="flex flex-col items-center gap-2 min-w-0 group"
>
<div className="w-16 h-16 rounded-full border-2 border-primary/30 bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center group-hover:scale-105 transition-transform">
@ -417,7 +410,7 @@ const UserProfile = () => {
{/* Pages Section */}
<div className="border-t p-2">
<PageManager userId={userId!} isOwnProfile={isOwnProfile} orgSlug={orgSlug} />
<PageManager userId={userId!} username={userProfile.username || undefined} isOwnProfile={isOwnProfile} orgSlug={orgSlug} />
</div>
{/* Tab Navigation */}
@ -472,12 +465,12 @@ const UserProfile = () => {
<div>
<PhotoGrid
customPictures={activeTab === 'posts' ? publicPosts : hiddenPosts}
customLoading={loading}
customLoading={feedLoading}
isOwner={isOwnProfile}
navigationSource="user"
navigationSourceId={userId}
/>
{isOwnProfile && activeTab === 'posts' && publicPosts.length === 0 && !loading && (
{isOwnProfile && activeTab === 'posts' && publicPosts.length === 0 && !feedLoading && (
<div className="text-center mt-8">
<Button
className="bg-primary hover:bg-primary/90 text-white"

View File

@ -12,7 +12,7 @@ export interface NavigationPost {
export interface NavigationData {
posts: NavigationPost[];
currentIndex: number;
source: 'home' | 'collection' | 'tag' | 'search' | 'user' | 'photogrid';
source: 'home' | 'collection' | 'tag' | 'search' | 'user' | 'photogrid' | 'widget';
sourceId?: string;
}

View File

@ -1,15 +1,16 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
import { NetworkFirst } from 'workbox-strategies';
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { set } from 'idb-keyval'
const SW_VERSION = '1.0.5-debug';
const SW_VERSION = '1.0.4-debug';
console.log(`[SW] Initializing Version: ${SW_VERSION}`);
self.addEventListener('fetch', (event) => { });
import { cleanupOutdatedCaches, cleanupOutdatedCaches as cleanupOutdatedCaches2, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { set } from 'idb-keyval'
declare let self: ServiceWorkerGlobalScope
self.addEventListener('message', (event) => {
@ -29,7 +30,7 @@ clientsClaim()
// allow only fallback in dev: we don't want to cache everything
let allowlist: undefined | RegExp[]
if (import.meta.env.DEV)
if (location.hostname === 'localhost' || location.hostname.includes('127.0.0.1'))
allowlist = [/^\/$/]
// Handle Share Target POST requests
@ -63,7 +64,6 @@ registerRoute(
'POST'
);
import { NetworkFirst } from 'workbox-strategies';
// Navigation handler: Prefer network to get server injection, fallback to index.html
const navigationHandler = async (params: any) => {

View File

@ -23,6 +23,7 @@ export interface MediaItem {
created_at: string;
updated_at: string;
likes_count: number | null;
is_liked?: boolean;
is_selected: boolean;
visible: boolean;
tags: string[] | null;
@ -31,7 +32,13 @@ export interface MediaItem {
parent_id: string | null;
position: number;
job?: any;
picture_id?: string;
responsive?: any; // To hold server-generated responsive data
author?: {
username: string;
display_name: string;
avatar_url: string;
};
}
// Mux Video Resolution Types
@ -174,6 +181,6 @@ export interface PostNavigationData {
comments_count?: number;
}[];
currentIndex: number;
source: 'home' | 'collection' | 'tag' | 'search' | 'user' | 'photogrid';
source: 'home' | 'collection' | 'tag' | 'search' | 'user' | 'photogrid' | 'widget';
sourceId?: string;
}