mono/packages/ui/src/modules/posts/client-posts.ts
2026-04-09 20:00:53 +02:00

266 lines
10 KiB
TypeScript

import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { UserProfile } from "@/modules/posts/views/types";
import { MediaType, MediaItem } from "@/types";
import { SupabaseClient } from "@supabase/supabase-js";
import { fetchWithDeduplication, getAuthToken } from "@/lib/db";
export interface FeedPost {
id: string; // Post ID
title: string;
description: string | null;
created_at: string;
user_id: string;
pictures: MediaItem[]; // All visible pictures
cover: MediaItem; // The selected cover picture
likes_count: number;
comments_count: number;
type: MediaType;
author?: UserProfile;
settings?: any;
is_liked?: boolean;
category_paths?: any[][]; // Array of category paths (each path is root -> leaf)
meta?: any; // Additional metadata
}
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 { getCurrentLang } = await import('@/i18n');
params.set('lang', getCurrentLang());
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 token = await getAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${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();
};
/** Fetch posts with cover pictures, paginated */
export const fetchPostsList = async (options: { page?: number; limit?: number; userId?: string } = {}) => {
const params = new URLSearchParams();
params.append('page', String(options.page ?? 0));
params.append('limit', String(options.limit ?? 30));
if (options.userId) params.append('userId', options.userId);
const res = await fetch(`/api/posts?${params}`);
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.statusText}`);
return await res.json() as { data: any[]; count: number; page: number; limit: number };
};
export const fetchPostById = async (id: string, client?: SupabaseClient) => {
// Use API-mediated fetching instead of direct Supabase calls
// This returns enriched FeedPost data including category_paths, author info, etc.
return fetchWithDeduplication(`post-${id}`, async () => {
const data = await fetchPostDetailsAPI(id);
if (!data) return null;
return data;
}, 1);
};
export const fetchFullPost = async (postId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`full-post-${postId}`, async () => {
const { data, error } = await supabase
.from('posts')
.select(`*, pictures (*)`)
.eq('id', postId)
.single();
if (error) throw error;
return data;
});
};
export const deletePost = async (id: string, client?: SupabaseClient) => {
const token = await getAuthToken();
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error(`Failed to delete post: ${response.statusText}`);
}
return await response.json();
};
export const createPost = async (postData: { title: string, description?: string, settings?: any, meta?: any }) => {
const token = await getAuthToken();
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(postData)
});
if (!response.ok) {
throw new Error(`Failed to create post: ${response.statusText}`);
}
return await response.json();
};
export const updatePostDetails = async (postId: string, updates: { title?: string, description?: string, settings?: any, meta?: any }, client?: SupabaseClient) => {
const token = await getAuthToken();
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error(`Failed to update post details: ${response.statusText}`);
}
return await response.json();
};
// Map FeedPost[] back to MediaItemType[] for backward compatibility w/ PhotoGrid
export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | 'top' = 'latest'): any[] => {
return posts.map(post => {
let cover = post.cover;
// Use server-provided cover (respects is_selected versions).
// Only fall back to position-based selection if no cover was provided.
if (!cover && post.pictures && post.pictures.length > 0) {
const validPics = post.pictures.filter((p: any) => p.visible !== false);
if (validPics.length > 0) {
const sortedByPosition = [...validPics].sort((a, b) => (a.position || 0) - (b.position || 0));
cover = sortedByPosition[0];
} else {
cover = post.pictures[0]; // Fallback to any picture
}
}
if (!cover) {
// Support items without covers that should still be displayed
const allowedWithoutCover = ['page-vfs-folder', 'page-vfs-file', 'page-external', 'page-intern', 'page-github', 'place-search'];
if (allowedWithoutCover.includes(post.type)) {
return {
id: post.id,
picture_id: post.id,
title: post.title,
description: post.description,
image_url: post.type === 'place-search' ? '' : (post.meta?.url || ''),
thumbnail_url: null,
type: post.type as MediaType,
meta: post.meta,
created_at: post.created_at,
user_id: post.user_id,
likes_count: post.likes_count,
comments: [{ count: post.comments_count }],
responsive: undefined,
job: undefined,
author: post.author,
versionCount: 1,
_searchSource: (post as any)._searchSource
};
}
return null;
}
const versionCount = post.pictures ? post.pictures.length : 1;
const coverType = ((cover as any).type || (cover as any).mediaType) as MediaType;
const finalType = (post.type && post.type.startsWith('page-')) ? (post.type as MediaType) : coverType;
return {
id: post.id,
picture_id: cover.id,
title: post.title,
description: post.description,
image_url: cover.image_url,
thumbnail_url: cover.thumbnail_url,
type: finalType,
meta: { ...(post.meta || {}), ...(cover.meta || {}) },
created_at: post.created_at,
user_id: post.user_id,
likes_count: post.likes_count,
comments: [{ count: post.comments_count }],
responsive: (cover as any).responsive,
job: (cover as any).job,
author: post.author,
versionCount,
_searchSource: (post as any)._searchSource
};
}).filter(item => item !== null);
};
// Augment posts if they come from API/Hydration (missing cover/author)
export const augmentFeedPosts = (posts: any[]): FeedPost[] => {
return posts.map(p => {
// Check if we need to augment (heuristic: missing cover)
if (!p.cover) {
const pics = p.pictures || [];
const validPics = pics.filter((pic: any) => pic.visible !== false)
.sort((a: any, b: any) => (a.position || 0) - (b.position || 0));
return {
...p,
cover: validPics[0] || pics[0], // fallback to first if none visible?
author: p.author || (p.author ? {
user_id: p.author.user_id,
username: p.author.username,
display_name: p.author.display_name,
avatar_url: p.author.avatar_url
} : undefined)
};
}
return p;
});
};
export const updatePostMeta = async (postId: string, meta: any, client?: SupabaseClient) => {
const token = await getAuthToken();
if (!token) throw new Error('No active session');
const response = await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ meta })
});
if (!response.ok) {
throw new Error(`Failed to update post meta: ${response.statusText}`);
}
return await response.json();
};