db | cache | kats:) | gallery | widgets

This commit is contained in:
lovebird 2026-02-06 12:31:07 +01:00
parent a5625856c0
commit 34fe0f1690
49 changed files with 3019 additions and 582 deletions

View File

@ -82,6 +82,7 @@ const AppWrapper = () => {
<Route path="/collections/new" element={<NewCollection />} />
<Route path="/collections/:userId/:slug" element={<Collections />} />
<Route path="/tags/:tag" element={<TagPage />} />
<Route path="/categories/:slug" element={<Index />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/wizard" element={<Wizard />} />
<Route path="/new" element={<NewPost />} />
@ -114,6 +115,7 @@ const AppWrapper = () => {
<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 />} />
<Route path="/org/:orgSlug/categories/:slug" element={<Index />} />
<Route path="/org/:orgSlug/search" element={<SearchResults />} />
<Route path="/org/:orgSlug/wizard" element={<Wizard />} />
<Route path="/org/:orgSlug/new" element={<NewPost />} />

View File

@ -1,16 +1,13 @@
import { MediaGrid, PhotoGrid } from "./PhotoGrid";
import MediaCard from "./MediaCard";
import React, { useEffect, useState, useRef } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useProfiles } from "@/contexts/ProfilesContext";
import { usePostNavigation } from "@/hooks/usePostNavigation";
import { useOrganization } from "@/contexts/OrganizationContext";
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 { MediaItem, MediaType } from "@/types";
import type { MediaItem } from "@/types";
import { supabase } from "@/integrations/supabase/client";
// Duplicate types for now or we could reuse specific generic props
@ -24,6 +21,7 @@ interface GalleryLargeProps {
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
navigationSourceId?: string;
sortBy?: FeedSortOption;
categorySlugs?: string[];
}
const GalleryLarge = ({
@ -31,7 +29,8 @@ const GalleryLarge = ({
customLoading,
navigationSource = 'home',
navigationSourceId,
sortBy = 'latest'
sortBy = 'latest',
categorySlugs
}: GalleryLargeProps) => {
const { user } = useAuth();
const navigate = useNavigate();
@ -48,6 +47,7 @@ const GalleryLarge = ({
isOrgContext,
orgSlug,
sortBy,
categorySlugs,
// Disable hook if we have custom pictures
enabled: !customPictures
});

View File

@ -17,6 +17,7 @@ interface ListLayoutProps {
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
navigationSourceId?: string;
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
categorySlugs?: string[];
}
const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolean, onClick: () => void }) => {
@ -95,7 +96,8 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea
export const ListLayout = ({
sortBy = 'latest',
navigationSource = 'home',
navigationSourceId
navigationSourceId,
categorySlugs
}: ListLayoutProps) => {
const navigate = useNavigate();
const isMobile = useIsMobile();
@ -114,7 +116,8 @@ export const ListLayout = ({
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy
sortBy,
categorySlugs
});
// console.log('posts', feedPosts);

View File

@ -36,6 +36,7 @@ interface PageActionsProps {
onToggleEditMode?: () => void;
onPageUpdate: (updatedPage: Page) => void;
onDelete?: () => void;
onMetaUpdated?: () => void;
className?: string;
showLabels?: boolean;
}
@ -47,6 +48,7 @@ export const PageActions = ({
onToggleEditMode,
onPageUpdate,
onDelete,
onMetaUpdated,
className,
showLabels = true
}: PageActionsProps) => {
@ -109,18 +111,24 @@ export const PageActions = ({
}
};
const handleMetaUpdate = (newMeta: any) => {
// PageActions locally updates the page object.
// Ideally we should reload the page via UserPage but this gives instant feedback.
const handleMetaUpdate = async (newMeta: any) => {
// Update local state immediately for responsive UI
onPageUpdate({ ...page, meta: newMeta });
// NOTE: If meta update persists to DB elsewhere (CategoryManager), it should probably handle invalidation too.
// But if CategoryManager is purely local until save, then we do nothing.
// Looking at CategoryManager usage, it likely saves.
// We might want to pass invalidatePageCache to it or call it here if we know it saved.
// Use timeout to debounce invalidation? For now assume CategoryManager handles its own saving/invalidation or we rely on page refresh.
// Actually, CategoryManager props has "onPageMetaUpdate", which updates local state.
// If CategoryManager saves to DB, it should invalidate.
// Let's stick to the handlers we control here.
// Persist to database
try {
const { updatePageMeta } = await import('@/lib/db');
await updatePageMeta(page.id, newMeta);
invalidatePageCache();
// Trigger parent refresh to get updated category_paths
if (onMetaUpdated) {
onMetaUpdated();
}
} catch (error) {
console.error('Failed to update page meta:', error);
toast.error(translate('Failed to update categories'));
}
};
const handleToggleVisibility = async (e?: React.MouseEvent) => {
@ -516,6 +524,8 @@ draft: ${!page.visible}
currentPageId={page.id}
currentPageMeta={page.meta}
onPageMetaUpdate={handleMetaUpdate}
filterByType="pages"
defaultMetaType="pages"
/>
{/* Legacy/Standard Parent Picker - Keeping relevant as "Page Hierarchy" vs "Category Taxonomy" */}

View File

@ -11,6 +11,8 @@ interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
variant?: 'grid' | 'feed';
responsive?: any;
showContent?: boolean;
showHeader?: boolean;
overlayMode?: 'hover' | 'always';
authorAvatarUrl?: string | null;
created_at?: string;
apiUrl?: string;
@ -35,6 +37,8 @@ const PageCard: React.FC<PageCardProps> = ({
variant = 'grid',
responsive,
showContent = true,
showHeader = true,
overlayMode = 'hover',
apiUrl,
versionCount
}) => {
@ -69,16 +73,18 @@ const PageCard: React.FC<PageCardProps> = ({
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full border rounded-lg mb-4"
onClick={handleCardClick}
>
<div className="p-4 border-b flex items-center justify-between">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
className="w-8 h-8"
showDate={true}
createdAt={created_at}
/>
</div>
{showHeader && (
<div className="p-4 border-b flex items-center justify-between">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
className="w-8 h-8"
showDate={true}
createdAt={created_at}
/>
</div>
)}
<div className={`relative w-full ${tikTokId ? 'aspect-[9/16]' : 'aspect-[16/9]'} overflow-hidden bg-muted`}>
{isPlaying && isExternalVideo ? (
@ -126,28 +132,30 @@ const PageCard: React.FC<PageCardProps> = ({
)}
</div>
<div className="p-4 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">{title}</h3>
</div>
{description && (
<div className="text-sm text-foreground/90 line-clamp-3">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
{showContent && (
<div className="p-4 space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold">{title}</h3>
</div>
)}
<div className="flex items-center gap-4 pt-2">
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
<Heart className={`h-5 w-5 ${isLiked ? "fill-red-500 text-red-500" : ""}`} />
<span>{likes}</span>
</Button>
<Button size="sm" variant="ghost" className="px-0 gap-1">
<MessageCircle className="h-5 w-5" />
<span>{comments}</span>
</Button>
{description && (
<div className="text-sm text-foreground/90 line-clamp-3">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
</div>
)}
<div className="flex items-center gap-4 pt-2">
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
<Heart className={`h-5 w-5 ${isLiked ? "fill-red-500 text-red-500" : ""}`} />
<span>{likes}</span>
</Button>
<Button size="sm" variant="ghost" className="px-0 gap-1">
<MessageCircle className="h-5 w-5" />
<span>{comments}</span>
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@ -208,7 +216,7 @@ const PageCard: React.FC<PageCardProps> = ({
)}
{showContent && (
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}>
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<UserAvatarBlock

View File

@ -35,6 +35,8 @@ interface PhotoCardProps {
createdAt?: string;
authorAvatarUrl?: string | null;
showContent?: boolean;
showHeader?: boolean;
overlayMode?: 'hover' | 'always';
responsive?: any;
variant?: 'grid' | 'feed';
apiUrl?: string;
@ -59,6 +61,8 @@ const PhotoCard = ({
createdAt,
authorAvatarUrl,
showContent = true,
showHeader = true,
overlayMode = 'hover',
responsive,
variant = 'grid',
apiUrl,
@ -409,18 +413,20 @@ const PhotoCard = ({
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
{showContent && variant === 'grid' && (
<div className="hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}>
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
</div>
{showHeader && (
<div className="flex items-center space-x-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
</div>
)}
<div className="flex items-center space-x-1">
<Button
size="sm"

View File

@ -13,7 +13,7 @@ import { useLayoutEffect } from "react";
import { useFeedData } from "@/hooks/useFeedData";
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
import { UploadCloud, Maximize } from "lucide-react";
import { UploadCloud, Maximize, FolderTree } from "lucide-react";
import { toast } from "sonner";
import type { MediaType } from "@/types";
@ -51,6 +51,7 @@ interface MediaGridProps {
sortBy?: FeedSortOption;
supabaseClient?: any;
apiUrl?: string;
categorySlugs?: string[];
}
const MediaGrid = ({
@ -63,7 +64,8 @@ const MediaGrid = ({
showVideos = true,
sortBy = 'latest',
supabaseClient,
apiUrl
apiUrl,
categorySlugs
}: MediaGridProps) => {
const { user } = useAuth();
// Use provided client or fallback to default
@ -95,6 +97,7 @@ const MediaGrid = ({
isOrgContext,
orgSlug,
sortBy,
categorySlugs,
// Disable hook if we have custom pictures
enabled: !customPictures,
supabaseClient
@ -182,8 +185,6 @@ const MediaGrid = ({
hasRestoredScroll.current = false;
}, [cacheKey]);
// Track scroll position
const lastScrollY = useRef(window.scrollY);
@ -374,6 +375,94 @@ const MediaGrid = ({
}
}, [mediaItems, customPictures, navigationSource, navigationSourceId, setNavigationData]);
// Group media items by category
// - When filtering by category: group by immediate subcategories (show parent + each child)
// - When on home feed: don't group (flat list)
const shouldGroupByCategory = !!categorySlugs && categorySlugs.length > 0;
const groupedItems = React.useMemo(() => {
if (!shouldGroupByCategory) {
return { sections: [{ key: 'all', title: null, items: mediaItems }] };
}
const grouped = new Map<string, MediaItemType[]>();
const parentCategoryItems: MediaItemType[] = [];
mediaItems.forEach(item => {
// Find corresponding feed post to get category_paths
const feedPost = feedPosts.find(p => p.id === item.id || p.cover?.id === item.id);
if (!feedPost?.category_paths || feedPost.category_paths.length === 0) {
return;
}
// Check if item has an exact match to the parent category (path length = 1)
const hasExactParentMatch = feedPost.category_paths.some((path: any[]) =>
path.length === 1 && categorySlugs.includes(path[0].slug)
);
if (hasExactParentMatch) {
// Item is directly in the parent category
parentCategoryItems.push(item);
return;
}
// Otherwise, find the most specific path (longest) that includes the filtered category
let mostSpecificPath: any[] | null = null;
let categoryIndex = -1;
for (const path of feedPost.category_paths) {
const idx = path.findIndex((cat: any) => categorySlugs.includes(cat.slug));
if (idx !== -1) {
if (!mostSpecificPath || path.length > mostSpecificPath.length) {
mostSpecificPath = path;
categoryIndex = idx;
}
}
}
if (!mostSpecificPath || categoryIndex === -1) return;
// Item is in a subcategory - use the immediate child category
const subcategory = mostSpecificPath[categoryIndex + 1];
if (subcategory) {
const key = subcategory.slug;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)!.push(item);
}
});
const sections = [];
// Add parent category section first (if it has items)
if (parentCategoryItems.length > 0) {
sections.push({
key: categorySlugs[0],
title: categorySlugs[0].replace(/-/g, ' '),
items: parentCategoryItems
});
}
// Add subcategory sections
Array.from(grouped.entries()).forEach(([key, items]) => {
sections.push({
key,
title: key.replace(/-/g, ' '),
items
});
});
// If no sections, return all items in one section
if (sections.length === 0) {
return { sections: [{ key: 'all', title: null, items: mediaItems }] };
}
return { sections };
}, [mediaItems, feedPosts, shouldGroupByCategory, categorySlugs, navigationSource]);
if (loading) {
return (
<div className="py-8">
@ -426,79 +515,91 @@ const MediaGrid = ({
</div>
)
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mediaItems.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const isVideo = isVideoType(itemType);
<>
{groupedItems.sections.map((section) => (
<div key={section.key} className="mb-8">
{section.title && (
<h2 className="text-lg font-semibold mb-4 px-4 flex items-center gap-2 capitalize">
<FolderTree className="h-5 w-5" />
{section.title}
</h2>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{section.items.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const isVideo = isVideoType(itemType);
// For images, convert URL to optimized format
const displayUrl = item.image_url;
// For images, convert URL to optimized format
const displayUrl = item.image_url;
if (isVideo) {
return (
<div key={item.id} className="relative group">
<MediaCard
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
// Pass blank/undefined so UserAvatarBlock uses context data
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => handleMediaClick(item.id, itemType, index)}
onLike={fetchUserLikes}
onDelete={fetchMediaFromPicturesTable}
onEdit={handleEditPost}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
/>
<div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<Maximize className="w-4 h-4 text-white" />
</div>
</div>
);
}
if (isVideo) {
return (
<div key={item.id} className="relative group">
<MediaCard
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
// Pass blank/undefined so UserAvatarBlock uses context data
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => handleMediaClick(item.id, itemType, index)}
onLike={fetchUserLikes}
onDelete={fetchMediaFromPicturesTable}
onEdit={handleEditPost}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
/>
<div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<Maximize className="w-4 h-4 text-white" />
</div>
</div>
);
}
return (
<MediaCard
key={item.id}
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => handleMediaClick(item.id, itemType, index)}
onLike={fetchUserLikes}
onDelete={fetchMediaFromPicturesTable}
onEdit={handleEditPost}
return (
<MediaCard
key={item.id}
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item?.comments?.[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => handleMediaClick(item.id, itemType, index)}
onLike={fetchUserLikes}
onDelete={fetchMediaFromPicturesTable}
onEdit={handleEditPost}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
/>
);
})}
</div>
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
/>
);
})}
</div>
</div>
))}
</>
)}
{/* Loading Indicator / Observer Target */}

View File

@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Home, User, Upload, LogOut, LogIn, Camera, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useLog } from "@/contexts/LogContext";
import { useWizardContext } from "@/hooks/useWizardContext";
import {
DropdownMenu,
@ -30,8 +29,7 @@ const TopNavigation = () => {
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const currentLang = getCurrentLang();
const { isLoggerVisible, setLoggerVisible } = useLog();
const { clearWizardImage, creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth';
@ -42,12 +40,9 @@ const TopNavigation = () => {
}, [user?.id, fetchProfile]);
const userProfile = user ? profiles[user.id] : null;
const username = userProfile?.username || user?.id;
const username = userProfile?.user_id || user?.id;
const isActive = (path: string) => location.pathname === path;
// ... (rest of component until link)
{/* Profile Grid Button - Direct to profile feed */ }
{
user && (

View File

@ -10,9 +10,9 @@ import {
useSidebar
} from "@/components/ui/sidebar";
import { T, translate } from "@/i18n";
import { LayoutDashboard, Users, Server } from "lucide-react";
import { LayoutDashboard, Users, Server, Shield, AlertTriangle } from "lucide-react";
export type AdminActiveSection = 'dashboard' | 'users' | 'server';
export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations';
export const AdminSidebar = ({
activeSection,
@ -28,6 +28,8 @@ export const AdminSidebar = ({
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
{ id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users },
{ id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server },
{ id: 'bans' as AdminActiveSection, label: translate('Bans'), icon: Shield },
{ id: 'violations' as AdminActiveSection, label: translate('Violations'), icon: AlertTriangle },
];
return (

View File

@ -0,0 +1,291 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Shield, Trash2, RefreshCw } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface BanList {
bannedIPs: string[];
bannedUserIds: string[];
bannedTokens: string[];
}
export const BansManager = ({ session }: { session: any }) => {
const [banList, setBanList] = useState<BanList>({
bannedIPs: [],
bannedUserIds: [],
bannedTokens: [],
});
const [loading, setLoading] = useState(false);
const [unbanTarget, setUnbanTarget] = useState<{ type: 'ip' | 'user'; value: string } | null>(null);
const fetchBanList = async () => {
try {
setLoading(true);
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/admin/bans`, {
headers: {
'Authorization': `Bearer ${session?.access_token || ''}`
}
});
if (!res.ok) {
throw new Error('Failed to fetch ban list');
}
const data = await res.json();
setBanList(data);
} catch (err: any) {
toast.error("Failed to fetch ban list", {
description: err.message
});
} finally {
setLoading(false);
}
};
const handleUnban = async () => {
if (!unbanTarget) return;
try {
const endpoint = unbanTarget.type === 'ip'
? '/api/admin/bans/unban-ip'
: '/api/admin/bans/unban-user';
const body = unbanTarget.type === 'ip'
? { ip: unbanTarget.value }
: { userId: unbanTarget.value };
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session?.access_token || ''}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!res.ok) {
throw new Error('Failed to unban');
}
const data = await res.json();
if (data.success) {
toast.success("Unbanned successfully", {
description: data.message
});
fetchBanList();
} else {
toast.warning("Not found", {
description: data.message
});
}
} catch (err: any) {
toast.error("Failed to unban", {
description: err.message
});
} finally {
setUnbanTarget(null);
}
};
useEffect(() => {
fetchBanList();
}, []);
const totalBans = banList.bannedIPs.length + banList.bannedUserIds.length + banList.bannedTokens.length;
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6" />
<h1 className="text-2xl font-bold">Ban Management</h1>
</div>
<Button onClick={fetchBanList} disabled={loading} variant="outline">
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Refresh
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3 mb-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Banned IPs</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banList.bannedIPs.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Banned Users</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banList.bannedUserIds.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Banned Tokens</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banList.bannedTokens.length}</div>
</CardContent>
</Card>
</div>
{totalBans === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No active bans
</CardContent>
</Card>
) : (
<div className="space-y-6">
{banList.bannedIPs.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Banned IP Addresses</CardTitle>
<CardDescription>
IP addresses that have been auto-banned for excessive requests
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>IP Address</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{banList.bannedIPs.map((ip) => (
<TableRow key={ip}>
<TableCell className="font-mono">{ip}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => setUnbanTarget({ type: 'ip', value: ip })}
>
<Trash2 className="h-4 w-4 mr-1" />
Unban
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{banList.bannedUserIds.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Banned Users</CardTitle>
<CardDescription>
User accounts that have been auto-banned for excessive requests
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{banList.bannedUserIds.map((userId) => (
<TableRow key={userId}>
<TableCell className="font-mono text-sm">{userId}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => setUnbanTarget({ type: 'user', value: userId })}
>
<Trash2 className="h-4 w-4 mr-1" />
Unban
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{banList.bannedTokens.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Banned Tokens</CardTitle>
<CardDescription>
Authentication tokens that have been auto-banned (cannot be unbanned via UI)
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Token (truncated)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{banList.bannedTokens.map((token, idx) => (
<TableRow key={idx}>
<TableCell className="font-mono text-sm">
{token.substring(0, 40)}...
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
<AlertDialog open={!!unbanTarget} onOpenChange={(open) => !open && setUnbanTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Unban</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to unban this {unbanTarget?.type}?
<div className="mt-2 p-2 bg-muted rounded font-mono text-sm">
{unbanTarget?.value}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleUnban}>Unban</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@ -0,0 +1,200 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { AlertTriangle, RefreshCw } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface ViolationRecord {
key: string;
count: number;
firstViolation: number;
lastViolation: number;
}
interface ViolationStats {
totalViolations: number;
violations: ViolationRecord[];
}
export const ViolationsMonitor = ({ session }: { session: any }) => {
const [stats, setStats] = useState<ViolationStats>({
totalViolations: 0,
violations: [],
});
const [loading, setLoading] = useState(false);
const fetchViolationStats = async () => {
try {
setLoading(true);
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/admin/bans/violations`, {
headers: {
'Authorization': `Bearer ${session?.access_token || ''}`
}
});
if (!res.ok) {
throw new Error('Failed to fetch violation stats');
}
const data = await res.json();
setStats(data);
} catch (err: any) {
toast.error("Failed to fetch violation stats", {
description: err.message
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchViolationStats();
// Auto-refresh every 5 seconds
const interval = setInterval(fetchViolationStats, 5000);
return () => clearInterval(interval);
}, []);
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const getViolationType = (key: string) => {
const [type] = key.split(':', 2);
return type;
};
const getViolationValue = (key: string) => {
const [, value] = key.split(':', 2);
return value;
};
const getSeverityColor = (count: number) => {
if (count >= 4) return "destructive";
if (count >= 2) return "default";
return "secondary";
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<AlertTriangle className="h-6 w-6" />
<h1 className="text-2xl font-bold">Violation Monitor</h1>
</div>
<Button onClick={fetchViolationStats} disabled={loading} variant="outline">
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Refresh
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 mb-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Active Violations</CardTitle>
<CardDescription>Currently tracked violation records</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalViolations}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Auto-Refresh</CardTitle>
<CardDescription>Updates every 5 seconds</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm text-muted-foreground">Live monitoring</span>
</div>
</CardContent>
</Card>
</div>
{stats.totalViolations === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No active violations
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Violation Records</CardTitle>
<CardDescription>
Entities approaching the ban threshold (5 violations within the configured window)
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Identifier</TableHead>
<TableHead>Count</TableHead>
<TableHead>First Violation</TableHead>
<TableHead>Last Violation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.violations
.sort((a, b) => b.count - a.count)
.map((violation) => (
<TableRow key={violation.key}>
<TableCell>
<Badge variant="outline" className="capitalize">
{getViolationType(violation.key)}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{getViolationValue(violation.key).substring(0, 40)}
{getViolationValue(violation.key).length > 40 && '...'}
</TableCell>
<TableCell>
<Badge variant={getSeverityColor(violation.count)}>
{violation.count} / 5
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatTimestamp(violation.firstViolation)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatTimestamp(violation.lastViolation)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Card className="mt-6">
<CardHeader>
<CardTitle>About Violations</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
<strong>Violation Tracking:</strong> The system tracks rate limit violations for IPs and authenticated users.
</p>
<p>
<strong>Auto-Ban Threshold:</strong> When an entity reaches 5 violations within the configured time window,
they are automatically banned and moved to the ban list.
</p>
<p>
<strong>Cleanup:</strong> Violation records are automatically cleaned up after the time window expires.
</p>
</CardContent>
</Card>
</div>
);
};

View File

@ -15,6 +15,7 @@ interface MobileFeedProps {
sourceId?: string;
onNavigate?: (id: string) => void;
sortBy?: FeedSortOption;
categorySlugs?: string[];
}
const PRELOAD_BUFFER = 3;
@ -23,7 +24,8 @@ export const MobileFeed: React.FC<MobileFeedProps> = ({
source = 'home',
sourceId,
onNavigate,
sortBy = 'latest'
sortBy = 'latest',
categorySlugs
}) => {
const { user } = useAuth();
@ -34,7 +36,8 @@ export const MobileFeed: React.FC<MobileFeedProps> = ({
const { posts, loading, error, hasMore } = useFeedData({
source,
sourceId,
sortBy
sortBy,
categorySlugs
});
// Scroll Restoration Logic

View File

@ -563,9 +563,9 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
{/* Widget Content - With selection wrapper */}
<div
className={cn(
"w-full bg-white dark:bg-slate-800 overflow-hidden transition-all duration-200",
"w-full bg-white dark:bg-slate-800 overflow-hidden rounded-lg transition-all duration-200",
// Selection Visuals & Margins
isEditMode && "rounded-lg border-2",
isEditMode && "border-2",
isEditMode && isSelected ? "border-blue-500 ring-4 ring-blue-500/10 shadow-lg z-10" : "border-transparent",
isEditMode && !isSelected && "hover:border-blue-300 dark:hover:border-blue-700",
// Margin between header/content - applied via padding on this wrapper or margin on content?

View File

@ -1,7 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { T } from '@/i18n';
import { Settings, FileJson, LayoutTemplate, Save, ClipboardList, Mail } from 'lucide-react';
import { Settings, FileJson, LayoutTemplate, Save, ClipboardList, Mail, MoreVertical, Eye, Pencil, Trash2, FolderTree } from 'lucide-react';
import { CategoryManager } from '@/components/widgets/CategoryManager';
import { updateLayoutMeta } from '@/lib/db';
import {
DropdownMenu,
DropdownMenuContent,
@ -11,7 +13,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LayoutTemplate as ILayoutTemplate } from '@/lib/layoutTemplates';
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
interface PlaygroundHeaderProps {
viewMode: 'design' | 'preview';
@ -21,9 +25,12 @@ interface PlaygroundHeaderProps {
htmlSize?: number;
// Template Menu
templates: ILayoutTemplate[];
handleLoadTemplate: (template: ILayoutTemplate) => void;
templates: Layout[];
handleLoadTemplate: (template: Layout) => void;
onSaveTemplateClick: () => void;
handleDeleteTemplate?: (layoutId: string) => void;
handleToggleVisibility?: (layoutId: string, currentVisibility: Database['public']['Enums']['layout_visibility']) => void;
handleRenameLayout?: (layoutId: string, newName: string) => void;
// Other Actions
onPasteJsonClick: () => void;
@ -44,12 +51,39 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
templates,
handleLoadTemplate,
onSaveTemplateClick,
handleDeleteTemplate,
handleToggleVisibility,
handleRenameLayout,
onPasteJsonClick,
handleDumpJson,
handleLoadContext,
isEditMode,
setIsEditMode
}) => {
const [editingLayoutId, setEditingLayoutId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [selectedLayoutForCategories, setSelectedLayoutForCategories] = useState<Layout | null>(null);
const getVisibilityIcon = (visibility: Database['public']['Enums']['layout_visibility']) => {
switch (visibility) {
case 'public': return '🌐';
case 'listed': return '📋';
case 'private': return '🔒';
default: return '❓';
}
};
const handleLayoutMetaUpdate = async (newMeta: any) => {
if (!selectedLayoutForCategories) return;
try {
await updateLayoutMeta(selectedLayoutForCategories.id, newMeta);
// Optionally refresh templates or update local state
} catch (error) {
console.error('Failed to update layout categories:', error);
}
};
return (
<div className="border-b bg-background/95 backdrop-blur z-10 shrink-0 p-4 flex flex-wrap gap-4 justify-between items-center">
<div>
@ -92,7 +126,7 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Predefined Layouts</DropdownMenuLabel>
<DropdownMenuGroup>
{templates.filter(t => t.isPredefined).map((t, i) => (
{templates.filter(t => t.is_predefined).map((t, i) => (
<DropdownMenuItem key={`pre-${i}`} onClick={() => handleLoadTemplate(t)}>
{t.name}
</DropdownMenuItem>
@ -101,13 +135,108 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
<DropdownMenuSeparator />
<DropdownMenuLabel>My Layouts</DropdownMenuLabel>
<DropdownMenuGroup>
{templates.filter(t => !t.isPredefined).length === 0 && (
{templates.filter(t => !t.is_predefined).length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No saved layouts</div>
)}
{templates.filter(t => !t.isPredefined).map((t, i) => (
<DropdownMenuItem key={`cust-${i}`} onClick={() => handleLoadTemplate(t)}>
{t.name}
</DropdownMenuItem>
{templates.filter(t => !t.is_predefined).map((t, i) => (
<div key={`cust-${i}`} className="flex items-center gap-1 px-1">
{editingLayoutId === t.id ? (
<div className="flex items-center gap-1 flex-1 py-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameLayout?.(t.id, editingName);
setEditingLayoutId(null);
} else if (e.key === 'Escape') {
setEditingLayoutId(null);
}
}}
className="flex-1 px-2 py-1 text-sm border rounded"
autoFocus
/>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
handleRenameLayout?.(t.id, editingName);
setEditingLayoutId(null);
}}
>
</Button>
</div>
) : (
<>
<DropdownMenuItem
className="flex-1 cursor-pointer"
onClick={() => handleLoadTemplate(t)}
>
<span className="mr-2">{getVisibilityIcon(t.visibility)}</span>
{t.name}
</DropdownMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleVisibility?.(t.id, t.visibility);
}}
>
<Eye className="mr-2 h-4 w-4" />
<span className="text-xs">{t.visibility}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditingLayoutId(t.id);
setEditingName(t.name);
}}
>
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setSelectedLayoutForCategories(t);
setShowCategoryManager(true);
}}
>
<FolderTree className="mr-2 h-4 w-4" />
Categories
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete "${t.name}"?`)) {
handleDeleteTemplate?.(t.id);
}
}}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
@ -150,8 +279,8 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
{htmlSize !== undefined && (
<div className={`text-xs px-2 py-1 rounded border gap-1 flex items-center ${htmlSize > 102000 ? 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800' :
htmlSize > 80000 ? 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800' :
'bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700'
htmlSize > 80000 ? 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-800' :
'bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700'
}`} title="Gmail clips HTML larger than 102KB">
<span className="font-mono">{(htmlSize / 1024).toFixed(1)}KB</span>
<span className="opacity-50">/ 102KB</span>
@ -178,6 +307,20 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
<T>{isEditMode ? 'Disable Edit Mode' : 'Enable Edit Mode'}</T>
</Button>
</div>
{/* Category Manager Dialog */}
<CategoryManager
isOpen={showCategoryManager}
onClose={() => {
setShowCategoryManager(false);
setSelectedLayoutForCategories(null);
}}
currentPageId={selectedLayoutForCategories?.id}
currentPageMeta={selectedLayoutForCategories?.meta}
onPageMetaUpdate={handleLayoutMetaUpdate}
filterByType="layout"
defaultMetaType="layout"
/>
</div>
);
};

View File

@ -146,15 +146,12 @@ export const FieldTemplate = (props: any) => {
{formattedLabel && (
<label
htmlFor={id}
className="block text-sm font-medium mb-1"
className="block text-sm font-medium mb-1"
>
{formattedLabel}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{description && (
<div className="text-sm text-gray-500 mb-2">{description}</div>
)}
{children}
{errors && errors.length > 0 && (
<div id={`${id}-error`} className="mt-1 text-sm text-red-600">
@ -168,7 +165,10 @@ export const FieldTemplate = (props: any) => {
// Custom ObjectFieldTemplate with Grouping Support
export const ObjectFieldTemplate = (props: any) => {
const { properties, schema, uiSchema } = props;
const { properties, schema, uiSchema, title, description } = props;
// Get custom classNames from uiSchema
const customClassNames = uiSchema?.['ui:classNames'] || '';
// Group properties based on uiSchema
const groups: Record<string, any[]> = {};
@ -196,10 +196,10 @@ export const ObjectFieldTemplate = (props: any) => {
if (!hasGroups) {
return (
<div className="space-y-4">
{props.description && (
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
{description && (typeof description !== 'string' || description.trim()) && (
<p className="text-sm text-gray-600 mb-4">{description}</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className={customClassNames || 'grid grid-cols-1 gap-4'}>
{properties.map((element: any) => (
<div key={element.name} className="w-full">
{element.content}

View File

@ -498,18 +498,23 @@ export const TypeBuilder: React.FC<{
setActiveDragItem(null);
if (over && over.id === 'canvas') {
const template = active.data.current as BuilderElement;
const template = active.data.current as BuilderElement & { refId?: string };
// Generate robust ID to avoid collisions
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
// Map JSON Schema type names to database primitive type names
const typeNameMap: Record<string, string> = {
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
};
const dbTypeName = typeNameMap[template.type] || template.type;
// Look up the type ID from availableTypes
const typeDefinition = availableTypes.find(t => t.name === dbTypeName);
// Determine the refId for this element
// If template already has refId (custom type from palette), use it
// Otherwise, look up primitive type by mapped name
let refId = (template as any).refId;
if (!refId) {
// Map JSON Schema type names to database primitive type names
const typeNameMap: Record<string, string> = {
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
};
const dbTypeName = typeNameMap[template.type] || template.type;
const typeDefinition = availableTypes.find(t => t.name === dbTypeName);
refId = typeDefinition?.id;
}
const newItem: BuilderElement = {
id: newItemId,
@ -518,7 +523,7 @@ export const TypeBuilder: React.FC<{
title: template.name,
description: '',
uiSchema: {},
...(typeDefinition && { refId: typeDefinition.id } as any) // Store the type ID
...(refId && { refId } as any) // Store the type ID
};
setElements(prev => [...prev, newItem]);

View File

@ -50,52 +50,107 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
'alias': { type: 'string' }
};
// For structures, generate JSON schema from structure_fields
if (editedType.kind === 'structure' && editedType.structure_fields && editedType.structure_fields.length > 0) {
const properties: Record<string, any> = {};
const required: string[] = [];
// Recursive function to generate schema for any type (primitive or structure)
const generateSchemaForType = (typeId: string, visited = new Set<string>()): any => {
// Prevent infinite recursion for circular references
if (visited.has(typeId)) {
return { type: 'object', description: 'Circular reference detected' };
}
editedType.structure_fields.forEach(field => {
// Find the field type to get its parent type
const fieldType = types.find(type => type.id === field.field_type_id);
if (fieldType && fieldType.parent_type_id) {
// Find the parent type (primitive)
const parentType = types.find(type => type.id === fieldType.parent_type_id);
if (parentType) {
// Use the primitive mapping to get the JSON Schema type
const jsonSchemaType = primitiveToJsonSchema[parentType.name] || { type: 'string' };
properties[field.field_name] = {
...jsonSchemaType,
title: field.field_name,
...(fieldType.description && { description: fieldType.description })
};
if (field.required) {
required.push(field.field_name);
const type = types.find(t => t.id === typeId);
if (!type) return { type: 'string' };
// If it's a primitive, return the JSON schema mapping
if (type.kind === 'primitive') {
return primitiveToJsonSchema[type.name] || { type: 'string' };
}
// If it's a structure, recursively build its schema
if (type.kind === 'structure' && type.structure_fields) {
visited.add(typeId);
const properties: Record<string, any> = {};
const required: string[] = [];
type.structure_fields.forEach(field => {
const fieldType = types.find(t => t.id === field.field_type_id);
if (fieldType && fieldType.parent_type_id) {
const parentType = types.find(t => t.id === fieldType.parent_type_id);
if (parentType) {
// Recursively generate schema for the parent type
properties[field.field_name] = {
...generateSchemaForType(parentType.id, new Set(visited)),
title: field.field_name,
...(fieldType.description && { description: fieldType.description })
};
if (field.required) {
required.push(field.field_name);
}
}
}
}
});
});
const generatedSchema = {
type: 'object',
properties,
...(required.length > 0 && { required })
};
return {
type: 'object',
properties,
...(required.length > 0 && { required })
};
}
// Fallback for other kinds
return { type: 'string' };
};
// For structures, generate JSON schema from structure_fields
if (editedType.kind === 'structure' && editedType.structure_fields && editedType.structure_fields.length > 0) {
const generatedSchema = generateSchemaForType(editedType.id);
setJsonSchemaString(JSON.stringify(generatedSchema, null, 2));
// Also aggregate UI schema from fields
const aggregatedUiSchema: Record<string, any> = {};
editedType.structure_fields.forEach(field => {
const fieldType = types.find(type => type.id === field.field_type_id);
if (fieldType && fieldType.meta?.uiSchema) {
aggregatedUiSchema[field.field_name] = fieldType.meta.uiSchema;
}
});
// Recursive function to generate UI schema for a type
const generateUiSchemaForType = (typeId: string, visited = new Set<string>()): any => {
if (visited.has(typeId)) return {};
// Merge with structure's own UI schema
const type = types.find(t => t.id === typeId);
if (!type || type.kind !== 'structure' || !type.structure_fields) {
return {};
}
visited.add(typeId);
const uiSchema: Record<string, any> = {
'ui:options': { orderable: false },
'ui:classNames': 'grid grid-cols-1 md:grid-cols-2 gap-4'
};
type.structure_fields.forEach(field => {
const fieldType = types.find(t => t.id === field.field_type_id);
const parentType = fieldType?.parent_type_id
? types.find(t => t.id === fieldType.parent_type_id)
: null;
const isNestedStructure = parentType?.kind === 'structure';
const fieldUiSchema = fieldType?.meta?.uiSchema || {};
if (isNestedStructure && parentType) {
// Recursively generate UI schema for nested structure
const nestedUiSchema = generateUiSchemaForType(parentType.id, new Set(visited));
uiSchema[field.field_name] = {
...fieldUiSchema,
...nestedUiSchema,
'ui:classNames': 'col-span-full border-t pt-4 mt-2'
};
} else {
uiSchema[field.field_name] = fieldUiSchema;
}
});
return uiSchema;
};
// Generate UI schema recursively
const generatedUiSchema = generateUiSchemaForType(editedType.id);
// Merge with structure's own UI schema (structure-level overrides)
const finalUiSchema = {
...aggregatedUiSchema,
...generatedUiSchema,
...(editedType.meta?.uiSchema || {})
};

View File

@ -95,11 +95,14 @@ const TypesPlayground: React.FC = () => {
// Find or create the field type
let fieldType = types.find(t => t.name === `${editedType.name}.${el.name}` && t.kind === 'field');
// Find the primitive type for this field
const primitiveType = types.find(t => t.kind === 'primitive' && t.name === el.type);
// Find the parent type for this field (could be primitive or custom)
// First check if element has a refId (for custom types dragged from palette)
let parentType = (el as any).refId
? types.find(t => t.id === (el as any).refId)
: types.find(t => t.name === el.type);
if (!primitiveType) {
console.error(`Primitive type not found: ${el.type}`);
if (!parentType) {
console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
return null;
}
@ -107,7 +110,7 @@ const TypesPlayground: React.FC = () => {
name: `${editedType.name}.${el.name}`,
kind: 'field' as const,
description: el.description || `Field ${el.name}`,
parent_type_id: primitiveType.id,
parent_type_id: parentType.id,
meta: {}
};
@ -159,16 +162,20 @@ const TypesPlayground: React.FC = () => {
// For structures, create field types first
if (output.mode === 'structure') {
const fieldTypes = await Promise.all(output.elements.map(async (el) => {
const primitiveType = types.find(t => t.kind === 'primitive' && t.name === el.type);
if (!primitiveType) {
throw new Error(`Primitive type not found: ${el.type}`);
// Find the parent type (could be primitive or custom)
const parentType = (el as any).refId
? types.find(t => t.id === (el as any).refId)
: types.find(t => t.name === el.type);
if (!parentType) {
throw new Error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
}
return await createType({
name: `${output.name}.${el.name}`,
kind: 'field',
description: el.description || `Field ${el.name}`,
parent_type_id: primitiveType.id,
parent_type_id: parentType.id,
meta: {}
} as any);
}));

View File

@ -9,16 +9,17 @@ import { toast } from "sonner";
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { T } from "@/i18n";
interface CategoryManagerProps {
isOpen: boolean;
onClose: () => void;
currentPageId?: string; // If provided, allows linking page to category
currentPageMeta?: any;
onPageMetaUpdate?: (newMeta: any) => void;
filterByType?: string; // Filter categories by meta.type (e.g., 'layout', 'page', 'email')
defaultMetaType?: string; // Default type to set in meta when creating new categories
}
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate }: CategoryManagerProps) => {
export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMeta, onPageMetaUpdate, filterByType, defaultMetaType }: CategoryManagerProps) => {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
@ -31,6 +32,8 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const [isCreating, setIsCreating] = useState(false);
const [creationParentId, setCreationParentId] = useState<string | null>(null);
// Initial linked category from page meta
const getLinkedCategoryIds = (): string[] => {
if (!currentPageMeta) return [];
@ -51,7 +54,16 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
setLoading(true);
try {
const data = await fetchCategories({ includeChildren: true });
setCategories(data);
// Filter by type if specified
let filtered = filterByType
? data.filter(cat => (cat as any).meta?.type === filterByType)
: data;
// Only show root-level categories (those without a parent)
// Children will be rendered recursively via the children property
filtered = filtered.filter(cat => !cat.parent_category_id);
setCategories(filtered);
} catch (error) {
console.error(error);
toast.error("Failed to load categories");
@ -85,11 +97,22 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
setActionLoading(true);
try {
if (isCreating) {
await createCategory({
// Apply default meta type if provided
const categoryData: any = {
...editingCategory,
parentId: creationParentId || undefined,
relationType: 'generalization'
});
};
// Set meta.type if defaultMetaType is provided
if (defaultMetaType) {
categoryData.meta = {
...(categoryData.meta || {}),
type: defaultMetaType
};
}
await createCategory(categoryData);
toast.success("Category created");
} else if (editingCategory.id) {
await updateCategory(editingCategory.id, editingCategory);
@ -132,15 +155,19 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
setActionLoading(true);
try {
const newIds = [...currentIds, selectedCategoryId];
// Clear legacy single ID if it exists to clean up, but prioritize setting array
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
toast.success("Page added to category");
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
// Use callback if provided, otherwise fall back to updatePageMeta
if (onPageMetaUpdate) {
onPageMetaUpdate({ ...currentPageMeta, categoryIds: newIds, categoryId: null });
await onPageMetaUpdate(newMeta);
} else {
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
}
toast.success("Added to category");
} catch (error) {
console.error(error);
toast.error("Failed to link page");
toast.error("Failed to link");
} finally {
setActionLoading(false);
}
@ -155,14 +182,19 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
setActionLoading(true);
try {
const newIds = currentIds.filter(id => id !== selectedCategoryId);
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
toast.success("Page removed from category");
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
// Use callback if provided, otherwise fall back to updatePageMeta
if (onPageMetaUpdate) {
onPageMetaUpdate({ ...currentPageMeta, categoryIds: newIds, categoryId: null });
await onPageMetaUpdate(newMeta);
} else {
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
}
toast.success("Removed from category");
} catch (error) {
console.error(error);
toast.error("Failed to unlink page");
toast.error("Failed to unlink");
} finally {
setActionLoading(false);
}
@ -182,7 +214,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
isLinked && !isSelected && "bg-primary/5" // Slight highlight for linked items not selected
)}
style={{ marginLeft: `${level * 16}px` }}
onClick={() => setSelectedCategoryId(cat.id)}
onClick={() => {
setSelectedCategoryId(cat.id);
}}
>
<div className="flex items-center gap-2">
{isLinked ? (
@ -206,7 +240,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</Button>
</div>
</div>
{cat.children?.map(childRel => renderCategoryItem(childRel.child, level + 1))}
{cat.children
?.filter(childRel => !filterByType || (childRel.child as any).meta?.type === filterByType)
.map(childRel => renderCategoryItem(childRel.child, level + 1))
}
</div>
);
};
@ -221,9 +258,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex gap-4 min-h-0">
<div className="flex-1 flex flex-col md:flex-row gap-4 min-h-0">
{/* Left: Category Tree */}
<div className="flex-1 border rounded-md p-2 overflow-y-auto">
<div className="flex-1 border rounded-md p-2 overflow-y-auto min-h-0 basis-[40%] md:basis-auto">
<div className="flex justify-between items-center mb-2 px-2">
<span className="text-sm font-semibold text-muted-foreground">Category Hierarchy</span>
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
@ -242,7 +279,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</div>
{/* Right: Editor or Actions */}
<div className="w-[300px] border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10">
<div className="border rounded-md p-4 flex flex-col gap-4 overflow-y-auto bg-muted/10 w-full md:w-[300px] border-t md:border-l-0 md:border-l min-h-[50%] md:min-h-0">
{editingCategory ? (
<div className="space-y-4">
<div className="flex items-center justify-between">

View File

@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import { Gallery } from '@/pages/Post/renderers/components/Gallery';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import SmartLightbox from '@/pages/Post/components/SmartLightbox';
import { T } from '@/i18n';
import { ImageIcon, Plus, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
import { PostMediaItem } from '@/pages/Post/types';
import { isVideoType, normalizeMediaType, detectMediaType } from '@/lib/mediaRegistry';
const { fetchMediaItemsByIds } = await import('@/lib/db');
interface GalleryWidgetProps {
pictureIds?: string[];
thumbnailLayout?: 'strip' | 'grid';
imageFit?: 'contain' | 'cover';
onPropsChange?: (props: Record<string, any>) => void;
isEditMode?: boolean;
id?: string;
}
const GalleryWidget: React.FC<GalleryWidgetProps> = ({
pictureIds: propPictureIds = [],
thumbnailLayout = 'strip',
imageFit = 'cover',
onPropsChange,
isEditMode = false,
id
}) => {
const { user } = useAuth();
const [pictureIds, setPictureIds] = useState<string[]>(propPictureIds || []);
const [mediaItems, setMediaItems] = useState<PostMediaItem[]>([]);
const [selectedItem, setSelectedItem] = useState<PostMediaItem | null>(null);
const [loading, setLoading] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
// Sync local state with props
useEffect(() => {
if (JSON.stringify(propPictureIds) !== JSON.stringify(pictureIds)) {
setPictureIds(propPictureIds || []);
}
}, [propPictureIds]);
useEffect(() => {
if (pictureIds && pictureIds.length > 0) {
fetchMediaItems();
} else {
setMediaItems([]);
setSelectedItem(null);
}
}, [pictureIds]);
const fetchMediaItems = async () => {
if (!pictureIds || pictureIds.length === 0) return;
setLoading(true);
try {
const items = await fetchMediaItemsByIds(pictureIds, { maintainOrder: true });
console.log('Fetched media items:', items);
// Transform to PostMediaItem format
const postMediaItems = items.map((item, index) => ({
...item,
post_id: '', // Not part of a post
position: index,
visible: true,
is_selected: false,
comments: [{ count: 0 }]
})) as PostMediaItem[];
console.log('Transformed to PostMediaItems:', postMediaItems);
setMediaItems(postMediaItems);
// Always set first item as selected when items change
if (postMediaItems.length > 0) {
console.log('Setting selected item:', postMediaItems[0]);
setSelectedItem(postMediaItems[0]);
}
} catch (error) {
console.error('Error fetching gallery media:', error);
} finally {
setLoading(false);
}
};
const handlePicturesSelected = (selectedPictureIds: string[]) => {
setPictureIds(selectedPictureIds);
if (onPropsChange) {
onPropsChange({ pictureIds: selectedPictureIds });
}
setShowPicker(false);
};
const handleMediaSelect = (item: PostMediaItem) => {
setSelectedItem(item);
};
const handleExpand = (item: PostMediaItem) => {
setSelectedItem(item);
setLightboxOpen(true);
};
const handlePublish = async (option: 'overwrite' | 'new' | 'version', imageUrl: string, newTitle: string, description?: string, parentId?: string, collectionIds?: string[]) => {
// TODO: Implement publish logic
console.log('Publish:', { option, imageUrl, newTitle, description, parentId, collectionIds });
};
const handleNavigateLightbox = (direction: 'prev' | 'next') => {
if (!selectedItem) return;
const currentIndex = mediaItems.findIndex(item => item.id === selectedItem.id);
const newIndex = direction === 'next'
? (currentIndex + 1) % mediaItems.length
: (currentIndex - 1 + mediaItems.length) % mediaItems.length;
setSelectedItem(mediaItems[newIndex]);
};
const handleOpenInWizard = () => {
// TODO: Implement wizard navigation
console.log('Open in wizard:', selectedItem);
};
// Empty state
if (!pictureIds || pictureIds.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-[400px] bg-muted/30 border-1 border-dashed">
<ImageIcon className="w-16 h-16 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
<T>No pictures selected</T>
</p>
{isEditMode && (
<Button onClick={() => setShowPicker(true)} variant="outline">
<Plus className="w-4 h-4 mr-2" />
<T>Select Pictures</T>
</Button>
)}
{showPicker && (
<ImagePickerDialog
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onMultiSelect={handlePicturesSelected}
multiple={true}
currentValues={pictureIds}
/>
)}
</div>
);
}
// Loading state
if (loading) {
return (
<div className="flex items-center justify-center h-full min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
// Gallery view
if (!selectedItem || mediaItems.length === 0) {
return null;
}
return (
<div className="relative w-full aspect-video flex flex-col">
{/* Edit Mode Controls */}
{isEditMode && (
<div className="absolute top-2 right-2 z-50">
<Button
onClick={() => setShowPicker(true)}
variant="secondary"
size="sm"
className="shadow-lg"
>
<Settings className="w-4 h-4 mr-2" />
<T>Configure</T>
</Button>
</div>
)}
{/* Gallery Component - needs to fill container */}
<div className="flex-1 w-full min-h-[500px]">
<Gallery
mediaItems={mediaItems}
selectedItem={selectedItem}
onMediaSelect={handleMediaSelect}
onExpand={handleExpand}
isOwner={!!user}
showDesktopLayout={true}
thumbnailLayout={thumbnailLayout}
imageFit={imageFit}
className="h-full w-full [&_.hidden]:!block"
/>
</div>
{/* Image Picker Dialog */}
{showPicker && (
<ImagePickerDialog
isOpen={showPicker}
onClose={() => setShowPicker(false)}
onMultiSelect={handlePicturesSelected}
multiple={true}
currentValues={pictureIds}
/>
)}
{/* Smart Lightbox for fullscreen viewing */}
{selectedItem && (
<SmartLightbox
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
mediaItem={selectedItem}
user={user}
isVideo={isVideoType(normalizeMediaType(selectedItem.mediaType || detectMediaType(selectedItem.image_url)))}
onPublish={handlePublish}
onNavigate={handleNavigateLightbox}
onOpenInWizard={handleOpenInWizard}
currentIndex={mediaItems.findIndex(item => item.id === selectedItem.id)}
totalCount={mediaItems.length}
/>
)}
</div>
);
};
export default GalleryWidget;

View File

@ -0,0 +1,246 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import PageCard from '@/components/PageCard';
import { PagePickerDialog } from './PagePickerDialog';
import { Button } from '@/components/ui/button';
import { FileText } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import type { MediaType } from '@/types';
import { fetchPageDetailsById } from '@/lib/db';
interface PageCardWidgetProps {
isEditMode?: boolean;
pageId?: string | null;
showHeader?: boolean;
showFooter?: boolean;
contentDisplay?: 'below' | 'overlay';
// Widget instance management
widgetInstanceId?: string;
onPropsChange?: (props: Record<string, any>) => void;
}
interface PageData {
id: string;
title: string;
description: string | null;
slug: string;
user_id: string;
likes_count: number;
comments_count: number;
image_url: string | null;
type: MediaType;
meta: any;
created_at: string;
}
const PageCardWidget: React.FC<PageCardWidgetProps> = ({
isEditMode = false,
pageId: propPageId = null,
showHeader = true,
showFooter = true,
contentDisplay = 'below',
onPropsChange
}) => {
const [pageId, setPageId] = useState<string | null>(propPageId);
const [page, setPage] = useState<PageData | null>(null);
const [loading, setLoading] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const { user } = useAuth();
// Sync prop changes
useEffect(() => {
setPageId(propPageId);
}, [propPageId]);
// Fetch page data
useEffect(() => {
if (pageId) {
fetchPageData(pageId);
} else {
setPage(null);
}
}, [pageId]);
const fetchPageData = async (id: string) => {
setLoading(true);
try {
// Use the server API that enriches page data with image URLs
const result = await fetchPageDetailsById(id, supabase);
if (!result || !result.page) {
setPage(null);
return;
}
const pageData = result.page;
const userProfile = result.userProfile;
// For now, using 0 for likes and comments since page_likes table doesn't exist
const likesCount = 0;
const commentsCount = 0;
// Server should have enriched the page with proper image URL
// Extract from meta or use fallback
let thumbnailUrl = (pageData.meta as any)?.thumbnail;
if (thumbnailUrl && (thumbnailUrl.includes('/pages/') || thumbnailUrl.includes('/user/'))) {
// Invalid thumbnail URL (it's a page URL, not an image URL)
thumbnailUrl = null;
}
// Transform to PageData format
const transformedPage: PageData = {
id: pageData.id,
title: pageData.title,
description: (pageData.meta as any)?.description || null,
slug: pageData.slug,
user_id: pageData.owner,
likes_count: likesCount,
comments_count: commentsCount,
image_url: thumbnailUrl || 'https://picsum.photos/640',
type: 'page-intern',
meta: pageData.meta,
created_at: pageData.created_at
};
setPage(transformedPage);
} catch (error) {
console.error('Error fetching page:', error);
setPage(null);
} finally {
setLoading(false);
}
};
const handleLike = async () => {
// Like functionality disabled until page_likes table is created
console.log('Like functionality not yet implemented');
};
const handlePageSelect = (selectedPageId: string | null) => {
setPageId(selectedPageId);
setShowPagePicker(false);
// Notify parent of prop change
if (onPropsChange) {
onPropsChange({ pageId: selectedPageId });
}
};
// Edit mode: Show page picker button
if (isEditMode && !pageId) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted/20 border-2 border-dashed border-muted-foreground/25 rounded-lg p-8">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">No page selected</p>
<Button onClick={() => setShowPagePicker(true)} variant="outline">
Select Page
</Button>
</div>
<PagePickerDialog
isOpen={showPagePicker}
onClose={() => setShowPagePicker(false)}
onSelect={handlePageSelect}
currentValue={pageId}
/>
</div>
);
}
// Edit mode with page selected: Show edit button overlay
if (isEditMode && pageId) {
return (
<div className="relative w-full h-full">
{page && (
<PageCard
id={page.id}
url={`/user/${page.user_id}/pages/${page.slug}`}
thumbnailUrl={page.image_url}
title={page.title}
author=""
authorId={page.user_id}
likes={page.likes_count}
comments={page.comments_count}
isLiked={isLiked}
description={page.description}
type={page.type}
meta={page.meta}
onClick={() => { }}
onLike={handleLike}
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
showHeader={showHeader}
showContent={showFooter}
created_at={page.created_at}
/>
)}
<div className="absolute top-2 right-2 z-10">
<Button
onClick={() => setShowPagePicker(true)}
size="sm"
variant="secondary"
>
Change Page
</Button>
</div>
<PagePickerDialog
isOpen={showPagePicker}
onClose={() => setShowPagePicker(false)}
onSelect={handlePageSelect}
currentValue={pageId}
/>
</div>
);
}
// Loading state
if (loading) {
return (
<div className="w-full h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
// No page selected (render mode)
if (!page) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted/20 rounded-lg">
<p className="text-sm text-muted-foreground">No page selected</p>
</div>
);
}
// Render mode: Show the page card
return (
<div className="w-full">
<PageCard
id={page.id}
url={`/user/${page.user_id}/pages/${page.slug}`}
thumbnailUrl={page.image_url}
title={page.title}
author=""
authorId={page.user_id}
likes={page.likes_count}
comments={page.comments_count}
isLiked={isLiked}
description={page.description}
type={page.type}
meta={page.meta}
onClick={(id) => {
// Navigate to page
window.location.href = `/user/${page.user_id}/pages/${page.slug}`;
}}
onLike={handleLike}
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
showHeader={showHeader}
showContent={showFooter}
created_at={page.created_at}
/>
</div>
);
};
export default PageCardWidget;

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@ -7,6 +6,7 @@ import { T, translate } from '@/i18n';
import { Search, FileText, Check } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { cn } from '@/lib/utils';
import { fetchUserPages } from '@/lib/db';
interface PagePickerDialogProps {
isOpen: boolean;
@ -23,6 +23,7 @@ interface Page {
is_public: boolean;
visible: boolean;
updated_at: string;
meta?: any;
}
export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
@ -51,13 +52,7 @@ export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase
.from('pages')
.select('id, title, slug, is_public, visible, updated_at')
.eq('owner', user.id)
.order('title', { ascending: true });
if (error) throw error;
const data = await fetchUserPages(user.id);
setPages(data || []);
} catch (error) {
console.error('Error fetching pages:', error);

View File

@ -9,6 +9,9 @@ import { Button } from '@/components/ui/button';
interface PhotoCardWidgetProps {
isEditMode?: boolean;
pictureId?: string | null;
showHeader?: boolean;
showFooter?: boolean;
contentDisplay?: 'below' | 'overlay' | 'overlay-always';
// Widget instance management
widgetInstanceId?: string;
onPropsChange?: (props: Record<string, any>) => void;
@ -32,6 +35,9 @@ interface UserProfile {
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
isEditMode = false,
pictureId: propPictureId = null,
showHeader = true,
showFooter = true,
contentDisplay = 'below',
onPropsChange
}) => {
const [pictureId, setPictureId] = useState<string | null>(propPictureId);
@ -57,6 +63,15 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
const fetchPictureData = async () => {
if (!pictureId) return;
// Validate that pictureId is a UUID, not an image URL
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(pictureId)) {
console.error('Invalid picture ID format. Expected UUID, got:', pictureId);
setPicture(null);
setLoading(false);
return;
}
setLoading(true);
try {
// Fetch picture
@ -166,9 +181,14 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
return (
<div className="bg-card border rounded-lg p-8 text-center">
<ImageIcon className="h-12 w-12 mx-auto mb-4 text-destructive opacity-50" />
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground mb-2">
<T>Picture not found</T>
</p>
{isEditMode && (
<p className="text-xs text-muted-foreground">
<T>Please select a new picture using the image picker</T>
</p>
)}
</div>
);
}
@ -193,7 +213,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
window.location.href = `/post/${id}`;
}}
onLike={handleLike}
variant="feed"
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
showHeader={showHeader}
showContent={showFooter}
/>
</div>

View File

@ -1,11 +1,9 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import PhotoGrid, { MediaItemType } from '@/components/PhotoGrid';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { T } from '@/i18n';
import { ImageIcon, Plus, Grid } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaType } from '@/lib/mediaRegistry';
import { useAuth } from '@/hooks/useAuth';
interface PhotoGridWidgetProps {
@ -54,38 +52,16 @@ const PhotoGridWidget: React.FC<PhotoGridWidgetProps> = ({ isEditMode = false, p
setLoading(true);
try {
const { data, error } = await supabase
.from('pictures')
.select('*')
.in('id', pictureIds)
.order('created_at', { ascending: false });
const { fetchMediaItemsByIds } = await import('@/lib/db');
const items = await fetchMediaItemsByIds(pictureIds, { maintainOrder: true });
if (error) throw error;
// Transform to MediaItem format expected by PhotoGrid
const items = (data || []).map(pic => ({
id: pic.id,
picture_id: pic.id, // For compatibility
title: pic.title,
description: pic.description,
image_url: pic.image_url,
thumbnail_url: pic.thumbnail_url || pic.image_url, // Fallback
type: (pic.type || 'image') as MediaType,
meta: pic.meta,
likes_count: pic.likes_count || 0,
created_at: pic.created_at,
user_id: pic.user_id,
comments: [{ count: 0 }] // Mock comments count as separate query is expensive/complex here
// Transform to format expected by PhotoGrid (add comments count)
const gridItems = items.map(item => ({
...item,
comments: [{ count: 0 }] // Mock comments count
}));
// Maintain order of selection if possible, otherwise by created_at
// To maintain selection order:
const orderedItems = pictureIds
.map(id => items.find(item => item.id === id))
.filter(Boolean);
setMediaItems(orderedItems as any[]);
setMediaItems(gridItems as any[]);
} catch (error) {
console.error('Error fetching grid media:', error);
} finally {

View File

@ -296,7 +296,7 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
setImagePickerField(null);
}}
onSelectPicture={(picture) => {
updateSetting(imagePickerField, picture.image_url);
updateSetting(imagePickerField, picture.id);
setImagePickerOpen(false);
setImagePickerField(null);
}}

View File

@ -1,14 +1,8 @@
import React, { useState, useEffect } from 'react';
import { T } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { WidgetDefinition } from '@/lib/widgetRegistry';
import { ImagePickerDialog } from './ImagePickerDialog';
import { Image as ImageIcon } from 'lucide-react';
import { WidgetPropertiesForm } from './WidgetPropertiesForm';
interface WidgetSettingsManagerProps {

View File

@ -157,8 +157,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const signOut = async () => {
const { error } = await supabase.auth.signOut();
debugger
if (error) {
toast({
title: "Sign out failed",

View File

@ -17,6 +17,8 @@ interface UseFeedDataProps {
enabled?: boolean;
sortBy?: FeedSortOption;
supabaseClient?: any; // Using any to avoid importing SupabaseClient type if not strictly needed here, or better import it
categoryIds?: string[];
categorySlugs?: string[];
}
export const useFeedData = ({
@ -26,10 +28,14 @@ export const useFeedData = ({
orgSlug,
enabled = true,
sortBy = 'latest',
supabaseClient
supabaseClient,
categoryIds,
categorySlugs
}: UseFeedDataProps) => {
const { getCache, saveCache } = useFeedCache();
const cacheKey = `${source}-${sourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}-${sortBy}`;
const categoryKey = categoryIds ? `-catIds${categoryIds.join(',')}` : '';
const slugKey = categorySlugs ? `-catSlugs${categorySlugs.join(',')}` : '';
const cacheKey = `${source}-${sourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}-${sortBy}${categoryKey}${slugKey}`;
// Initialize from cache if available
const [posts, setPosts] = useState<FeedPost[]>(() => {
@ -127,6 +133,8 @@ export const useFeedData = ({
let queryParams = `?page=${currentPage}&limit=${FEED_PAGE_SIZE}&sortBy=${sortBy}`;
if (source) queryParams += `&source=${source}`;
if (sourceId) queryParams += `&sourceId=${sourceId}`;
if (categoryIds && categoryIds.length > 0) queryParams += `&categoryIds=${categoryIds.join(',')}`;
if (categorySlugs && categorySlugs.length > 0) queryParams += `&categorySlugs=${categorySlugs.join(',')}`;
// If we have token, pass it?
// The Supabase client in the hook prop (supabaseClient) or defaultSupabase usually has the session.
@ -198,7 +206,7 @@ export const useFeedData = ({
setLoading(false);
setIsFetchingMore(false);
}
}, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, supabaseClient]);
}, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, supabaseClient, categoryIds, categorySlugs]);
// Initial Load
useEffect(() => {

View File

@ -0,0 +1,126 @@
import { supabase } from '@/integrations/supabase/client';
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
type LayoutInsert = Database['public']['Tables']['layouts']['Insert'];
type LayoutUpdate = Database['public']['Tables']['layouts']['Update'];
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
export interface UseLayoutsReturn {
getLayouts: (filters?: {
visibility?: LayoutVisibility;
type?: string;
limit?: number;
offset?: number;
}) => Promise<{ data: Layout[] | null; error: any }>;
getLayout: (id: string) => Promise<{ data: Layout | null; error: any }>;
createLayout: (layout: Omit<LayoutInsert, 'owner_id'>) => Promise<{ data: Layout | null; error: any }>;
updateLayout: (id: string, updates: LayoutUpdate) => Promise<{ data: Layout | null; error: any }>;
deleteLayout: (id: string) => Promise<{ error: any }>;
}
export function useLayouts(): UseLayoutsReturn {
const getLayouts = async (filters?: {
visibility?: LayoutVisibility;
type?: string;
limit?: number;
offset?: number;
}) => {
try {
let query = supabase
.from('layouts')
.select('*')
.order('updated_at', { ascending: false });
if (filters?.visibility) {
query = query.eq('visibility', filters.visibility);
}
if (filters?.type) {
query = query.eq('type', filters.type);
}
const limit = filters?.limit || 50;
const offset = filters?.offset || 0;
query = query.range(offset, offset + limit - 1);
const { data, error } = await query;
return { data, error };
} catch (error) {
return { data: null, error };
}
};
const getLayout = async (id: string) => {
try {
const { data, error } = await supabase
.from('layouts')
.select('*')
.eq('id', id)
.single();
return { data, error };
} catch (error) {
return { data: null, error };
}
};
const createLayout = async (layout: Omit<LayoutInsert, 'owner_id'>) => {
try {
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { data: null, error: new Error('Not authenticated') };
}
const { data, error } = await supabase
.from('layouts')
.insert({
...layout,
owner_id: user.id
})
.select()
.single();
return { data, error };
} catch (error) {
return { data: null, error };
}
};
const updateLayout = async (id: string, updates: LayoutUpdate) => {
try {
const { data, error } = await supabase
.from('layouts')
.update(updates)
.eq('id', id)
.select()
.single();
return { data, error };
} catch (error) {
return { data: null, error };
}
};
const deleteLayout = async (id: string) => {
try {
const { error } = await supabase
.from('layouts')
.delete()
.eq('id', id);
return { error };
} catch (error) {
return { error };
}
};
return {
getLayouts,
getLayout,
createLayout,
updateLayout,
deleteLayout
};
}

View File

@ -1,8 +1,12 @@
import { useState, useEffect } from 'react';
import { useLayout } from '@/contexts/LayoutContext';
import { toast } from 'sonner';
import { LayoutTemplateManager, LayoutTemplate as ILayoutTemplate } from '@/lib/layoutTemplates';
import { useWidgetLoader } from './useWidgetLoader.tsx';
import { useLayouts } from './useLayouts';
import { Database } from '@/integrations/supabase/types';
type Layout = Database['public']['Tables']['layouts']['Row'];
type LayoutVisibility = Database['public']['Enums']['layout_visibility'];
export function usePlaygroundLogic() {
// UI State
@ -26,23 +30,38 @@ export function usePlaygroundLogic() {
loadPageLayout
} = useLayout();
// Template State
const [templates, setTemplates] = useState<ILayoutTemplate[]>([]);
// Template State (now from Supabase)
const [templates, setTemplates] = useState<Layout[]>([]);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [newTemplateName, setNewTemplateName] = useState('');
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
// Paste JSON State
const [isPasteDialogOpen, setIsPasteDialogOpen] = useState(false);
const [pasteJsonContent, setPasteJsonContent] = useState('');
const { loadWidgetBundle } = useWidgetLoader();
const { getLayouts, createLayout, updateLayout, deleteLayout } = useLayouts();
useEffect(() => {
refreshTemplates();
}, []);
const refreshTemplates = () => {
setTemplates(LayoutTemplateManager.getTemplates());
const refreshTemplates = async () => {
setIsLoadingTemplates(true);
try {
const { data, error } = await getLayouts({ type: 'canvas' });
if (error) {
console.error('Failed to load layouts:', error);
toast.error('Failed to load layouts');
} else {
setTemplates(data || []);
}
} catch (e) {
console.error('Failed to refresh templates:', e);
} finally {
setIsLoadingTemplates(false);
}
};
// Initialization Effect
@ -155,32 +174,49 @@ export function usePlaygroundLogic() {
}
};
const handleLoadTemplate = async (template: ILayoutTemplate) => {
const handleLoadTemplate = async (template: Layout) => {
try {
await importPageLayout(pageId, template.layoutJson);
toast.success(`Loaded template: ${template.name}`);
// layout_json is already a parsed object, convert to string for importPageLayout
const layoutJsonString = JSON.stringify(template.layout_json);
await importPageLayout(pageId, layoutJsonString);
toast.success(`Loaded layout: ${template.name}`);
setLayoutJson(null);
} catch (e) {
console.error("Failed to load template", e);
toast.error("Failed to load template");
console.error("Failed to load layout", e);
toast.error("Failed to load layout");
}
};
const handleSaveTemplate = async () => {
if (!newTemplateName.trim()) {
toast.error("Please enter a template name");
toast.error("Please enter a layout name");
return;
}
try {
const json = await exportPageLayout(pageId);
LayoutTemplateManager.saveTemplate(newTemplateName.trim(), json);
toast.success("Template saved locally");
const layoutObject = JSON.parse(json);
const { data, error } = await createLayout({
name: newTemplateName.trim(),
layout_json: layoutObject,
type: 'canvas',
visibility: 'private' as LayoutVisibility,
meta: {}
});
if (error) {
console.error('Failed to save layout:', error);
toast.error('Failed to save layout');
return;
}
toast.success("Layout saved to database");
setIsSaveDialogOpen(false);
setNewTemplateName('');
refreshTemplates();
await refreshTemplates();
} catch (e) {
console.error("Failed to save template", e);
toast.error("Failed to save template");
console.error("Failed to save layout", e);
toast.error("Failed to save layout");
}
};
@ -202,6 +238,72 @@ export function usePlaygroundLogic() {
}
};
const handleDeleteTemplate = async (layoutId: string) => {
try {
const { error } = await deleteLayout(layoutId);
if (error) {
console.error('Failed to delete layout:', error);
toast.error('Failed to delete layout');
return;
}
toast.success('Layout deleted');
await refreshTemplates();
} catch (e) {
console.error('Failed to delete layout:', e);
toast.error('Failed to delete layout');
}
};
const handleToggleVisibility = async (layoutId: string, currentVisibility: LayoutVisibility) => {
try {
// Cycle through visibility options: private -> listed -> public -> private
const visibilityOrder: LayoutVisibility[] = ['private', 'listed', 'public'];
const currentIndex = visibilityOrder.indexOf(currentVisibility);
const newVisibility = visibilityOrder[(currentIndex + 1) % visibilityOrder.length];
const { error } = await updateLayout(layoutId, {
visibility: newVisibility
});
if (error) {
console.error('Failed to update visibility:', error);
toast.error('Failed to update visibility');
return;
}
toast.success(`Layout visibility: ${newVisibility}`);
await refreshTemplates();
} catch (e) {
console.error('Failed to toggle visibility:', e);
toast.error('Failed to toggle visibility');
}
};
const handleRenameLayout = async (layoutId: string, newName: string) => {
if (!newName.trim()) {
toast.error('Layout name cannot be empty');
return;
}
try {
const { error } = await updateLayout(layoutId, {
name: newName.trim()
});
if (error) {
console.error('Failed to rename layout:', error);
toast.error('Failed to rename layout');
return;
}
toast.success('Layout renamed');
await refreshTemplates();
} catch (e) {
console.error('Failed to rename layout:', e);
toast.error('Failed to rename layout');
}
};
const handleLoadContext = async () => {
const bundleUrl = '/widgets/email/library.json';
try {
@ -327,6 +429,7 @@ export function usePlaygroundLogic() {
pageName,
layoutJson,
templates,
isLoadingTemplates,
isSaveDialogOpen, setIsSaveDialogOpen,
newTemplateName, setNewTemplateName,
isPasteDialogOpen, setIsPasteDialogOpen,
@ -336,6 +439,9 @@ export function usePlaygroundLogic() {
handleDumpJson,
handleLoadTemplate,
handleSaveTemplate,
handleDeleteTemplate,
handleToggleVisibility,
handleRenameLayout,
handlePasteJson,
handleLoadContext,
handleExportHtml,

View File

@ -44,6 +44,7 @@ export type Database = {
created_at: string
description: string | null
id: string
meta: Json | null
name: string
owner_id: string | null
slug: string
@ -54,6 +55,7 @@ export type Database = {
created_at?: string
description?: string | null
id?: string
meta?: Json | null
name: string
owner_id?: string | null
slug: string
@ -64,6 +66,7 @@ export type Database = {
created_at?: string
description?: string | null
id?: string
meta?: Json | null
name?: string
owner_id?: string | null
slug?: string
@ -376,6 +379,45 @@ export type Database = {
},
]
}
layouts: {
Row: {
created_at: string
id: string
is_predefined: boolean | null
layout_json: Json
meta: Json | null
name: string
owner_id: string
type: string | null
updated_at: string
visibility: Database["public"]["Enums"]["layout_visibility"]
}
Insert: {
created_at?: string
id?: string
is_predefined?: boolean | null
layout_json: Json
meta?: Json | null
name: string
owner_id: string
type?: string | null
updated_at?: string
visibility?: Database["public"]["Enums"]["layout_visibility"]
}
Update: {
created_at?: string
id?: string
is_predefined?: boolean | null
layout_json?: Json
meta?: Json | null
name?: string
owner_id?: string
type?: string | null
updated_at?: string
visibility?: Database["public"]["Enums"]["layout_visibility"]
}
Relationships: []
}
likes: {
Row: {
created_at: string
@ -1231,7 +1273,14 @@ export type Database = {
| "other"
category_visibility: "public" | "unlisted" | "private"
collaborator_role: "viewer" | "editor" | "owner"
type_kind: "primitive" | "enum" | "flags" | "structure" | "alias"
layout_visibility: "public" | "private" | "listed" | "custom"
type_kind:
| "primitive"
| "enum"
| "flags"
| "structure"
| "alias"
| "field"
type_visibility: "public" | "private" | "custom"
}
CompositeTypes: {
@ -1390,7 +1439,8 @@ export const Constants = {
],
category_visibility: ["public", "unlisted", "private"],
collaborator_role: ["viewer", "editor", "owner"],
type_kind: ["primitive", "enum", "flags", "structure", "alias"],
layout_visibility: ["public", "private", "listed", "custom"],
type_kind: ["primitive", "enum", "flags", "structure", "alias", "field"],
type_visibility: ["public", "private", "custom"],
},
},

View File

@ -17,6 +17,7 @@ export interface FeedPost {
author?: UserProfile;
settings?: any;
is_liked?: boolean;
category_paths?: any[][]; // Array of category paths (each path is root -> leaf)
}
const requestCache = new Map<string, Promise<any>>();
@ -100,22 +101,14 @@ export const invalidateCache = (key: string) => {
};
export const fetchPostById = async (id: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
// 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, error } = await supabase
.from('posts')
.select(`
*,
pictures (
*
)
`)
.eq('id', id)
.maybeSingle();
if (error) throw error;
const { fetchPostDetailsAPI } = await import('@/pages/Post/db');
const data = await fetchPostDetailsAPI(id);
if (!data) return null;
return data;
});
}, 1);
};
export const fetchPictureById = async (id: string, client?: SupabaseClient) => {
@ -137,6 +130,43 @@ export const fetchPictureById = async (id: string, client?: SupabaseClient) => {
});
};
/**
* Fetch multiple media items by IDs using the server API endpoint
* This leverages the server's caching layer for optimal performance
*/
export const fetchMediaItemsByIds = async (
ids: string[],
options?: {
maintainOrder?: boolean;
client?: SupabaseClient;
}
): Promise<MediaItem[]> => {
if (!ids || ids.length === 0) return [];
// Build query parameters
const params = new URLSearchParams({
ids: ids.join(','),
});
if (options?.maintainOrder) {
params.append('maintainOrder', 'true');
}
// Call server API endpoint
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3333';
const response = await fetch(`${serverUrl}/api/media-items?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch media items: ${response.statusText}`);
}
const data = await response.json();
// The server returns raw Supabase data, so we need to adapt it
const { adaptSupabasePicturesToMediaItems } = await import('@/pages/Post/adapters');
return adaptSupabasePicturesToMediaItems(data);
};
export const fetchPageById = async (id: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
return fetchWithDeduplication(`page-${id}`, async () => {
@ -398,6 +428,45 @@ export const fetchUserPage = async (userId: string, slug: string, client?: Supab
}, 10);
};
export const fetchUserPages = async (userId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const key = `user-pages-${userId}`;
// Cache for 30 seconds
return fetchWithDeduplication(key, async () => {
const { data, error } = await supabase
.from('pages')
.select('id, title, slug, is_public, visible, updated_at, meta')
.eq('owner', userId)
.order('title', { ascending: true });
if (error) throw error;
return data || [];
}, 30000);
};
export const fetchPageDetailsById = async (pageId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const key = `page-details-${pageId}`;
// Cache for 30 seconds
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}`;
// Use page ID as both identifier and slug - server will detect UUID in slug position
const res = await fetch(`/api/user-page/${pageId}/${pageId}`, { headers });
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch page details: ${res.statusText}`);
}
return await res.json();
}, 30000);
};
export const invalidateUserPageCache = (userId: string, slug: string) => {
const key = `user-page-${userId}-${slug}`;
invalidateCache(key);
@ -844,7 +913,7 @@ export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | '
description: post.description,
image_url: cover.image_url,
thumbnail_url: cover.thumbnail_url,
type: cover.type as MediaType,
type: cover.mediaType as MediaType,
meta: cover.meta,
created_at: post.created_at,
user_id: post.user_id,
@ -962,7 +1031,7 @@ export const updatePageMeta = async (pageId: string, metaUpdates: any) => {
// Using Supabase client directly since this interacts with 'pages' table
const { data: page, error: fetchError } = await defaultSupabase
.from('pages')
.select('meta')
.select('meta, owner, slug')
.eq('id', pageId)
.single();
@ -979,5 +1048,69 @@ export const updatePageMeta = async (pageId: string, metaUpdates: any) => {
.single();
if (error) throw error;
// Invalidate caches
invalidateCache(`page-details-${pageId}`);
if (page.owner && page.slug) {
invalidateUserPageCache(page.owner, page.slug);
}
return data;
};
export const updatePostMeta = async (postId: string, metaUpdates: any) => {
// Fetch current post to merge meta
const { data: post, error: fetchError } = await defaultSupabase
.from('posts')
.select('meta')
.eq('id', postId)
.single();
if (fetchError) throw fetchError;
const currentMeta = (post?.meta as any) || {};
const newMeta = { ...currentMeta, ...metaUpdates };
const { data, error } = await defaultSupabase
.from('posts')
.update({ meta: newMeta, updated_at: new Date().toISOString() })
.eq('id', postId)
.select()
.single();
if (error) throw error;
// Invalidate post cache
invalidateCache(`post-${postId}`);
invalidateCache(`full-post-${postId}`);
return data;
};
export const updateLayoutMeta = async (layoutId: string, metaUpdates: any) => {
// Fetch current layout to merge meta
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
// Get current layout
const getRes = await fetch(`/api/layouts/${layoutId}`, { headers });
if (!getRes.ok) throw new Error(`Failed to fetch layout: ${getRes.statusText}`);
const layout = await getRes.json();
const currentMeta = (layout?.meta as any) || {};
const newMeta = { ...currentMeta, ...metaUpdates };
// Update layout with merged meta
const updateRes = await fetch(`/api/layouts/${layoutId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ meta: newMeta })
});
if (!updateRes.ok) throw new Error(`Failed to update layout meta: ${updateRes.statusText}`);
return await updateRes.json();
};

View File

@ -2,27 +2,39 @@
* Media Type Registry
*
* Unified system for handling different media types in the 'pictures' table
* based on the 'type' column:
* based on the 'type' column.
*
* - NULL or 'supabase-image' => Traditional images (PhotoCard)
* - 'mux-video' => Mux-powered videos (MuxVideoCard)
* - 'youtube' => YouTube videos
* This file contains utility functions and the renderer registry.
* Type definitions are in @/types.ts
*/
import React from 'react';
import {
MEDIA_TYPES,
MediaType,
MediaItem,
ImageMediaItem,
VideoMediaItem,
YouTubeMediaItem,
TikTokMediaItem,
PageMediaItem,
} from '@/types';
// Media type identifiers
export const MEDIA_TYPES = {
SUPABASE_IMAGE: 'supabase-image',
MUX_VIDEO: 'mux-video',
VIDEO_INTERN: 'video-intern',
YOUTUBE: 'youtube',
TIKTOK: 'tiktok',
PAGE: 'page-intern',
PAGE_EXTERNAL: 'page-external',
} as const;
// Re-export for convenience
export {
MEDIA_TYPES,
type MediaType,
type MediaItem,
type ImageMediaItem,
type VideoMediaItem,
type YouTubeMediaItem,
type TikTokMediaItem,
type PageMediaItem,
};
export type MediaType = typeof MEDIA_TYPES[keyof typeof MEDIA_TYPES] | null;
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
// Normalize type - treat NULL as SUPABASE_IMAGE (backward compatibility)
export function normalizeMediaType(type: string | null | undefined): MediaType {
@ -53,6 +65,42 @@ export function getThumbnailField(type: MediaType): 'thumbnail_url' | null {
return 'thumbnail_url';
}
// ============================================================================
// TYPE GUARDS
// ============================================================================
export function isImageMediaItem(item: MediaItem): item is ImageMediaItem {
return item.mediaType === 'supabase-image';
}
export function isVideoMediaItem(item: MediaItem): item is VideoMediaItem {
return item.mediaType === 'mux-video' || item.mediaType === 'video-intern';
}
export function isYouTubeMediaItem(item: MediaItem): item is YouTubeMediaItem {
return item.mediaType === 'youtube';
}
export function isTikTokMediaItem(item: MediaItem): item is TikTokMediaItem {
return item.mediaType === 'tiktok';
}
export function isPageMediaItem(item: MediaItem): item is PageMediaItem {
return item.mediaType === 'page-intern' || item.mediaType === 'page-external';
}
export function isExternalVideoItem(item: MediaItem): item is YouTubeMediaItem | TikTokMediaItem {
return isYouTubeMediaItem(item) || isTikTokMediaItem(item);
}
export function isAnyVideoItem(item: MediaItem): item is VideoMediaItem | YouTubeMediaItem | TikTokMediaItem {
return isVideoMediaItem(item) || isExternalVideoItem(item);
}
// ============================================================================
// MEDIA RENDERER REGISTRY (LEGACY - for backward compatibility)
// ============================================================================
// Media renderer registry
export interface MediaRendererProps {
id: string;

View File

@ -7,12 +7,13 @@ import {
} from 'lucide-react';
// Import your components
import LogViewerWidget from '@/components/widgets/LogViewerWidget';
import PhotoGrid from '@/components/PhotoGrid';
import PhotoGridWidget from '@/components/widgets/PhotoGridWidget';
import PhotoCardWidget from '@/components/widgets/PhotoCardWidget';
import PageCardWidget from '@/components/widgets/PageCardWidget';
import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget';
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
import GalleryWidget from '@/components/widgets/GalleryWidget';
export function registerAllWidgets() {
// Clear existing registrations (useful for HMR)
@ -28,6 +29,8 @@ export function registerAllWidgets() {
description: 'Display photos in a responsive grid layout',
icon: Monitor,
defaultProps: {},
// Note: PhotoGrid fetches data internally based on navigation context
// For configurable picture selection, use 'photo-grid-widget' instead
minSize: { width: 300, height: 200 },
resizable: true,
tags: ['photo', 'grid', 'gallery']
@ -43,7 +46,10 @@ export function registerAllWidgets() {
description: 'Display a single photo card with details',
icon: Monitor,
defaultProps: {
pictureId: null
pictureId: null,
showHeader: true,
showFooter: true,
contentDisplay: 'below'
},
configSchema: {
pictureId: {
@ -51,6 +57,29 @@ export function registerAllWidgets() {
label: 'Select Picture',
description: 'Choose a picture from your published images',
default: null
},
showHeader: {
type: 'boolean',
label: 'Show Header',
description: 'Show header with author information',
default: true
},
showFooter: {
type: 'boolean',
label: 'Show Footer',
description: 'Show footer with likes, comments, and actions',
default: true
},
contentDisplay: {
type: 'select',
label: 'Content Display',
description: 'How to display title and description',
options: [
{ value: 'below', label: 'Below Image' },
{ value: 'overlay', label: 'Overlay on Hover' },
{ value: 'overlay-always', label: 'Overlay (Always)' }
],
default: 'below'
}
},
minSize: { width: 300, height: 400 },
@ -84,6 +113,103 @@ export function registerAllWidgets() {
}
});
widgetRegistry.register({
component: GalleryWidget,
metadata: {
id: 'gallery-widget',
name: 'Gallery',
category: 'custom',
description: 'Interactive gallery with main viewer and filmstrip navigation',
icon: Monitor,
defaultProps: {
pictureIds: []
},
configSchema: {
pictureIds: {
type: 'imagePicker',
label: 'Select Pictures',
description: 'Choose pictures to display in the gallery',
default: [],
multiSelect: true
},
thumbnailLayout: {
type: 'select',
label: 'Thumbnail Layout',
description: 'How to display thumbnails below the main image',
options: [
{ value: 'strip', label: 'Horizontal Strip (scrollable)' },
{ value: 'grid', label: 'Grid (multiple rows, fit width)' }
],
default: 'strip'
},
imageFit: {
type: 'select',
label: 'Main Image Fit',
description: 'How the main image should fit within its container',
options: [
{ value: 'contain', label: 'Contain (fit within bounds, show full image)' },
{ value: 'cover', label: 'Cover (fill container, may crop image)' }
],
default: 'cover'
}
},
minSize: { width: 600, height: 500 },
resizable: true,
tags: ['photo', 'gallery', 'viewer', 'slideshow']
}
});
widgetRegistry.register({
component: PageCardWidget,
metadata: {
id: 'page-card',
name: 'Page Card',
category: 'custom',
description: 'Display a single page card with details',
icon: FileText,
defaultProps: {
pageId: null,
showHeader: true,
showFooter: true,
contentDisplay: 'below'
},
configSchema: {
pageId: {
type: 'pagePicker',
label: 'Select Page',
description: 'Choose a page to display',
default: null
},
showHeader: {
type: 'boolean',
label: 'Show Header',
description: 'Show header with author information',
default: true
},
showFooter: {
type: 'boolean',
label: 'Show Footer',
description: 'Show footer with likes, comments, and actions',
default: true
},
contentDisplay: {
type: 'select',
label: 'Content Display',
description: 'How to display title and description',
options: [
{ value: 'below', label: 'Below Image' },
{ value: 'overlay', label: 'Overlay on Hover' },
{ value: 'overlay-always', label: 'Overlay (Always)' }
],
default: 'below'
}
},
minSize: { width: 300, height: 400 },
resizable: true,
tags: ['page', 'card', 'display']
}
});
// Content widgets
widgetRegistry.register({
component: MarkdownTextWidget,
@ -143,85 +269,4 @@ export function registerAllWidgets() {
}
});
// System widgets
widgetRegistry.register({
component: LogViewerWidget,
metadata: {
id: 'log-viewer',
name: 'Log Viewer',
category: 'system',
description: 'Real-time WebSocket log viewer with filtering and search',
icon: ListFilter,
defaultProps: {
height: 400,
autoScroll: true,
defaultTab: 'all',
showSearch: true,
showFilters: true,
showControls: true,
maxLogEntries: 1000
},
configSchema: {
height: {
type: 'number',
label: 'Height (px)',
description: 'Widget height in pixels',
min: 200,
max: 800,
default: 400
},
autoScroll: {
type: 'boolean',
label: 'Auto Scroll',
description: 'Automatically scroll to new log entries',
default: true
},
defaultTab: {
type: 'select',
label: 'Default Tab',
description: 'Default log level tab to show',
options: [
{ value: 'all', label: 'All Logs' },
{ value: 'error', label: 'Errors' },
{ value: 'warning', label: 'Warnings' },
{ value: 'info', label: 'Info' },
{ value: 'debug', label: 'Debug' },
{ value: 'trace', label: 'Trace' },
{ value: 'verbose', label: 'Verbose' }
],
default: 'all'
},
showSearch: {
type: 'boolean',
label: 'Show Search',
description: 'Show search input field',
default: true
},
showFilters: {
type: 'boolean',
label: 'Show Filters',
description: 'Show component filter dropdown',
default: true
},
showControls: {
type: 'boolean',
label: 'Show Controls',
description: 'Show control buttons (clear, download, etc.)',
default: true
},
maxLogEntries: {
type: 'number',
label: 'Max Log Entries',
description: 'Maximum number of log entries to keep in memory',
min: 100,
max: 5000,
default: 1000
}
},
minSize: { width: 400, height: 300 },
resizable: true,
tags: ['logs', 'debug', 'websocket', 'system', 'monitoring']
}
});
}

View File

@ -7,7 +7,9 @@ import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar, AdminActiveSection } from "@/components/admin/AdminSidebar";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Server, RefreshCw } from "lucide-react";
import { Server, RefreshCw, Power } from "lucide-react";
import { BansManager } from "@/components/admin/BansManager";
import { ViolationsMonitor } from "@/components/admin/ViolationsMonitor";
const AdminPage = () => {
const { user, session, loading, roles } = useAuth();
@ -36,6 +38,8 @@ const AdminPage = () => {
{activeSection === 'users' && <UserManagerSection />}
{activeSection === 'dashboard' && <DashboardSection />}
{activeSection === 'server' && <ServerSection session={session} />}
{activeSection === 'bans' && <BansSection session={session} />}
{activeSection === 'violations' && <ViolationsSection session={session} />}
</div>
</main>
</div>
@ -57,6 +61,14 @@ const DashboardSection = () => (
</div>
);
const BansSection = ({ session }: { session: any }) => (
<BansManager session={session} />
);
const ViolationsSection = ({ session }: { session: any }) => (
<ViolationsMonitor session={session} />
);
const ServerSection = ({ session }: { session: any }) => {
const [loading, setLoading] = useState(false);
@ -87,6 +99,35 @@ const ServerSection = ({ session }: { session: any }) => {
}
};
const handleRestart = async () => {
if (!confirm('Are you sure you want to restart the server? This will cause a brief downtime.')) {
return;
}
try {
const res = await fetch(`${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/admin/system/restart`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session?.access_token || ''}`
}
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to restart server');
}
const data = await res.json();
toast.success("Server restarting", {
description: data.message
});
} catch (err: any) {
toast.error("Failed to restart server", {
description: err.message
});
}
};
return (
<div>
<div className="flex items-center gap-2 mb-6">
@ -115,6 +156,27 @@ const ServerSection = ({ session }: { session: any }) => {
</Button>
</div>
</div>
<div className="bg-card border rounded-lg p-6 max-w-2xl mt-6">
<h2 className="text-lg font-semibold mb-4">System Control</h2>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Restart Server</p>
<p className="text-sm text-muted-foreground mt-1">
Gracefully restart the server process. Systemd will automatically bring it back online.
This will cause a brief downtime.
</p>
</div>
<Button
onClick={handleRestart}
variant="destructive"
>
<Power className="mr-2 h-4 w-4" />
Restart
</Button>
</div>
</div>
</div>
);
};

View File

@ -4,12 +4,15 @@ import MobileFeed from "@/components/feed/MobileFeed";
import { useMediaRefresh } from "@/contexts/MediaRefreshContext";
import { useIsMobile } from "@/hooks/use-mobile";
import { useState, useEffect } from "react";
import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List } from "lucide-react";
import { useParams } from "react-router-dom";
import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree } from "lucide-react";
import { ListLayout } from "@/components/ListLayout";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { FeedSortOption } from "@/hooks/useFeedData";
const Index = () => {
const { slug } = useParams<{ slug?: string }>();
const categorySlugs = slug ? [slug] : undefined;
const { refreshKey } = useMediaRefresh();
const isMobile = useIsMobile();
@ -22,27 +25,42 @@ const Index = () => {
}, [viewMode]);
const [sortBy, setSortBy] = useState<FeedSortOption>('latest');
const renderCategoryBreadcrumb = () => {
if (!slug) return null;
// Fallback if no category path found
return (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<FolderTree className="h-4 w-4 shrink-0" />
<a href={`/categories/${slug}`} className="hover:text-primary transition-colors hover:underline capitalize">
{slug.replace(/-/g, ' ')}
</a>
</div>
);
};
return (
<div className="bg-background">
<div className="md:py-2">
<div className="text-center">
</div>
<div>
<div>
{/* Mobile Feed View */}
{isMobile ? (
<div className="md:hidden">
<div className="flex justify-between items-center px-4 mb-4 pt-2">
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
<ToggleGroupItem value="latest" aria-label="Latest Posts" size="sm">
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts" size="sm">
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center gap-3">
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
<ToggleGroupItem value="latest" aria-label="Latest Posts" size="sm">
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts" size="sm">
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
{renderCategoryBreadcrumb()}
</div>
<ToggleGroup type="single" value={viewMode === 'list' ? 'list' : 'grid'} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Card View" size="sm">
@ -54,25 +72,28 @@ const Index = () => {
</ToggleGroup>
</div>
{viewMode === 'list' ? (
<ListLayout key={refreshKey} sortBy={sortBy} navigationSource="home" />
<ListLayout key={refreshKey} sortBy={sortBy} navigationSource="home" categorySlugs={categorySlugs} />
) : (
<MobileFeed source="home" sortBy={sortBy} onNavigate={(id) => window.location.href = `/post/${id}`} />
<MobileFeed source="home" sortBy={sortBy} categorySlugs={categorySlugs} onNavigate={(id) => window.location.href = `/post/${id}`} />
)}
</div>
) : (
/* Desktop/Tablet Grid View */
<div className="hidden md:block">
<div className="flex justify-between px-4 mb-4">
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
<ToggleGroupItem value="latest" aria-label="Latest Posts">
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts">
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center gap-3">
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
<ToggleGroupItem value="latest" aria-label="Latest Posts">
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts">
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
{renderCategoryBreadcrumb()}
</div>
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Grid View">
@ -88,11 +109,11 @@ const Index = () => {
</div>
{viewMode === 'grid' ? (
<PhotoGrid key={refreshKey} sortBy={sortBy} showVideos={true} />
<PhotoGrid key={refreshKey} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} />
) : viewMode === 'large' ? (
<GalleryLarge key={refreshKey} sortBy={sortBy} />
<GalleryLarge key={refreshKey} sortBy={sortBy} categorySlugs={categorySlugs} />
) : (
<ListLayout key={refreshKey} sortBy={sortBy} />
<ListLayout key={refreshKey} sortBy={sortBy} categorySlugs={categorySlugs} />
)}
</div>
)}

View File

@ -31,6 +31,9 @@ const PlaygroundCanvas = () => {
handleDumpJson,
handleLoadTemplate,
handleSaveTemplate,
handleDeleteTemplate,
handleToggleVisibility,
handleRenameLayout,
handlePasteJson,
handleLoadContext,
handleExportHtml,
@ -141,6 +144,9 @@ const PlaygroundCanvas = () => {
onSendTestEmail={handleSendTestEmail}
templates={templates}
handleLoadTemplate={handleLoadTemplate}
handleDeleteTemplate={handleDeleteTemplate}
handleToggleVisibility={handleToggleVisibility}
handleRenameLayout={handleRenameLayout}
onSaveTemplateClick={() => setIsSaveDialogOpen(true)}
onPasteJsonClick={() => setIsPasteDialogOpen(true)}
handleDumpJson={handleDumpJson}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, Suspense, lazy } from "react";
import { useParams, useNavigate, useLocation, useSearchParams } from "react-router-dom";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { Image as ImageIcon, Wand2, ArrowUp, ArrowDown, Plus, Save, X, Edit3, Heart, MessageCircle, Maximize, User, Download, Share2, ArrowLeft, Trash2, Map, MoreVertical, FileText, LayoutGrid, StretchHorizontal, Youtube, Music } from 'lucide-react';
import { X } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { usePostNavigation } from "@/hooks/usePostNavigation";
@ -18,6 +18,8 @@ import { CompactRenderer } from "./Post/renderers/CompactRenderer";
import { usePostActions } from "./Post/usePostActions";
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
import { DeleteDialog } from "./Post/components/DeleteDialogs";
import { CategoryManager } from "@/components/widgets/CategoryManager";
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
@ -49,7 +51,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
const navigate = useNavigate();
const { user } = useAuth();
const { navigationData, setNavigationData, preloadImage } = usePostNavigation();
const { navigationData, setNavigationData } = usePostNavigation();
const { setWizardImage } = useWizardContext();
// ... state ...
@ -347,6 +349,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
);
};
const moveItem = (index: number, direction: 'up' | 'down') => {
const newItems = [...localMediaItems];
if (direction === 'up' && index > 0) {
@ -409,15 +412,6 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
}
};
const fetchAuthorProfile = async () => {
if (!mediaItem?.user_id) return;
try {
const profile = await db.fetchAuthorProfile(mediaItem.user_id);
if (profile) setAuthorProfile(profile);
} catch (error) {
console.error('Error fetching author profile:', error);
}
};
useEffect(() => {
if (id) {
@ -741,15 +735,6 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
toast.success(translate("TikTok video added"));
};
const checkIfLiked = async (targetId?: string) => {
if (!user || !targetId) return;
try {
const liked = await db.checkLikeStatus(user.id, targetId);
setIsLiked(liked);
} catch (error) {
console.error('Error checking like status:', error);
}
};
const handleLike = async () => {
if (!user || !mediaItem) {
@ -920,6 +905,8 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
onMediaSelect: setMediaItem,
onExpand: (item: MediaItem) => { setMediaItem(item); setShowLightbox(true); },
onDownload: handleDownload,
onCategoryManagerOpen: () => actions.setShowCategoryManager(true),
currentImageIndex,
videoPlaybackUrl,
@ -1110,6 +1097,16 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
</div>
</div>
)}
<CategoryManager
isOpen={actions.showCategoryManager}
onClose={() => actions.setShowCategoryManager(false)}
currentPageId={post?.id}
currentPageMeta={post?.meta}
onPageMetaUpdate={actions.handleMetaUpdate}
filterByType="pages"
defaultMetaType="pages"
/>
</Suspense>
</div>
);

View File

@ -0,0 +1,175 @@
import { Database } from '@/integrations/supabase/types';
import {
MediaItem,
ImageMediaItem,
VideoMediaItem,
YouTubeMediaItem,
TikTokMediaItem,
PageMediaItem,
MEDIA_TYPES,
} from '@/types';
import { detectMediaType } from '@/lib/mediaRegistry';
type SupabasePicture = Database['public']['Tables']['pictures']['Row'];
/**
* Adapter to convert Supabase picture row to MediaItem discriminated union
*/
export function adaptSupabasePictureToMediaItem(
picture: SupabasePicture
): MediaItem {
// Determine the media type
const mediaType = picture.type || detectMediaType(picture.image_url, picture.meta);
// Base fields common to all media types
const base = {
id: picture.id,
user_id: picture.user_id,
title: picture.title,
description: picture.description,
created_at: picture.created_at,
updated_at: picture.updated_at,
likes_count: picture.likes_count ?? 0,
is_selected: picture.is_selected,
visible: picture.visible,
tags: picture.tags,
flags: picture.flags,
organization_id: picture.organization_id,
parent_id: picture.parent_id,
position: picture.position ?? 0,
};
// Create the appropriate discriminated union type based on mediaType
switch (mediaType) {
case MEDIA_TYPES.SUPABASE_IMAGE:
case null:
return {
...base,
mediaType: 'supabase-image',
image_url: picture.image_url,
thumbnail_url: picture.thumbnail_url,
responsive: (picture.meta as any)?.responsive,
job: (picture.meta as any)?.job,
meta: picture.meta,
} as ImageMediaItem;
case MEDIA_TYPES.MUX_VIDEO:
return {
...base,
mediaType: 'mux-video',
video_url: picture.image_url,
image_url: picture.image_url, // For backward compatibility
thumbnail_url: picture.thumbnail_url ?? '',
duration: (picture.meta as any)?.duration,
meta: picture.meta as any,
} as VideoMediaItem;
case MEDIA_TYPES.VIDEO_INTERN:
return {
...base,
mediaType: 'video-intern',
video_url: picture.image_url,
image_url: picture.image_url, // For backward compatibility
thumbnail_url: picture.thumbnail_url ?? '',
duration: (picture.meta as any)?.duration,
meta: picture.meta as any,
} as VideoMediaItem;
case MEDIA_TYPES.YOUTUBE:
return {
...base,
mediaType: 'youtube',
videoId: extractYouTubeId(picture.image_url) ?? '',
image_url: picture.image_url,
thumbnail_url: picture.thumbnail_url ?? '',
meta: { url: picture.image_url, ...(picture.meta as any) },
} as YouTubeMediaItem;
case MEDIA_TYPES.TIKTOK:
return {
...base,
mediaType: 'tiktok',
videoId: extractTikTokId(picture.image_url) ?? '',
image_url: picture.image_url,
thumbnail_url: picture.thumbnail_url ?? '',
meta: { url: picture.image_url, ...(picture.meta as any) },
} as TikTokMediaItem;
case MEDIA_TYPES.PAGE:
return {
...base,
mediaType: 'page-intern',
image_url: picture.image_url,
thumbnail_url: picture.thumbnail_url,
slug: (picture.meta as any)?.slug,
url: (picture.meta as any)?.url,
meta: picture.meta as any,
} as PageMediaItem;
case MEDIA_TYPES.PAGE_EXTERNAL:
return {
...base,
mediaType: 'page-external',
image_url: picture.image_url,
thumbnail_url: picture.thumbnail_url,
slug: (picture.meta as any)?.slug,
url: (picture.meta as any)?.url,
meta: picture.meta as any,
} as PageMediaItem;
default:
// Fallback to image for unknown types
return {
...base,
mediaType: 'supabase-image',
image_url: picture.image_url,
thumbnail_url: picture.thumbnail_url,
meta: picture.meta,
} as ImageMediaItem;
}
}
/**
* Extract YouTube video ID from URL
*/
function extractYouTubeId(url: string): string | null {
// Handle various YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\s]+)/,
/youtube\.com\/embed\/([^?&\s]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}
/**
* Extract TikTok video ID from URL
*/
function extractTikTokId(url: string): string | null {
// Handle TikTok URL formats
const patterns = [
/tiktok\.com\/@[\w.-]+\/video\/(\d+)/,
/tiktok\.com\/v\/(\d+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}
/**
* Batch adapter for multiple pictures
*/
export function adaptSupabasePicturesToMediaItems(
pictures: SupabasePicture[]
): MediaItem[] {
return pictures.map(adaptSupabasePictureToMediaItem);
}

View File

@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect } from "react";
import React, { } from "react";
import { useNavigate } from "react-router-dom";
import { useOrganization } from "@/contexts/OrganizationContext";
import { PostRendererProps } from '../types';
@ -6,11 +6,10 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { isVideoType, normalizeMediaType, detectMediaType } from "@/lib/mediaRegistry";
// Extracted Components
import { CompactFilmStrip } from "./components/CompactFilmStrip";
import { MobileGroupedFeed } from "./components/MobileGroupedFeed";
import { CompactPostHeader } from "./components/CompactPostHeader";
import { CompactMediaViewer } from "./components/CompactMediaViewer";
import { CompactMediaDetails } from "./components/CompactMediaDetails";
import { Gallery } from "./components/Gallery";
// Lazy load ImageEditor
const ImageEditor = React.lazy(() => import("@/components/ImageEditor").then(module => ({ default: module.ImageEditor })));
@ -21,7 +20,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
isOwner, isLiked, likesCount,
onEditPost, onViewModeChange, onExportMarkdown,
onDeletePost, onDeletePicture, onLike, onEditPicture,
onMediaSelect, onExpand, onDownload,
onMediaSelect, onExpand, onDownload, onCategoryManagerOpen,
currentImageIndex, videoPlaybackUrl, videoPosterUrl,
versionImages, handlePrevImage, handleNavigate, navigationData,
isEditMode, localPost, setLocalPost, localMediaItems, setLocalMediaItems, onMoveItem,
@ -42,6 +41,12 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
const effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
const isVideo = isVideoType(normalizeMediaType(effectiveType));
console.log('mediaItem', mediaItem);
console.log('isVideo', isVideo);
console.log('effectiveType', effectiveType);
console.log('mediaItems', mediaItems);
return (
<div className={props.className || "h-full"}>
{/* Mobile Header - Controls and Info at Top */}
@ -61,6 +66,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
onEditPost={onEditPost!}
onDeletePicture={onDeletePicture!}
onDeletePost={onDeletePost!}
onCategoryManagerOpen={onCategoryManagerOpen}
mediaItems={mediaItems}
localMediaItems={localMediaItems}
/>
@ -72,43 +78,29 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
{/* Left Column - Media */}
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative lg:h-full`}>
{/* Row 1: Top Spacer */}
<div className="hidden lg:block h-[50px]"></div>
{/* Row 2: Main Media Container - DESKTOP ONLY */}
<div className="hidden lg:block flex-1 relative w-full min-h-0 group">
<CompactMediaViewer
mediaItem={mediaItem}
isVideo={isVideo}
showDesktopLayout={!!showDesktopLayout}
{/* Desktop Gallery - Combines Media Viewer + Filmstrip */}
<div className="hidden lg:block h-full">
<Gallery
mediaItems={mediaItems}
currentImageIndex={currentImageIndex}
navigationData={navigationData}
handleNavigate={handleNavigate!}
selectedItem={mediaItem}
onMediaSelect={onMediaSelect}
onExpand={onExpand}
cacheBustKeys={cacheBustKeys}
navigate={navigate}
isOwner={!!isOwner}
isEditMode={!!isEditMode}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
onDeletePicture={onDeletePicture!}
onGalleryPickerOpen={onGalleryPickerOpen!}
cacheBustKeys={cacheBustKeys}
navigationData={navigationData}
handleNavigate={handleNavigate!}
navigate={navigate}
videoPlaybackUrl={videoPlaybackUrl}
videoPosterUrl={videoPosterUrl}
showDesktopLayout={!!showDesktopLayout}
/>
</div>
{/* Row 3: Filmstrip (Media Items) */}
<CompactFilmStrip
mediaItems={mediaItems}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
isEditMode={!!isEditMode}
mediaItem={mediaItem}
onMediaSelect={onMediaSelect}
isOwner={!!isOwner}
onDeletePicture={onDeletePicture!}
onGalleryPickerOpen={onGalleryPickerOpen!}
cacheBustKeys={cacheBustKeys}
/>
{/* Mobile View - Grouped Feed - Hidden on Desktop */}
<MobileGroupedFeed
mediaItems={mediaItems}
@ -150,6 +142,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
onEditPost={onEditPost!}
onDeletePicture={onDeletePicture!}
onDeletePost={onDeletePost!}
onCategoryManagerOpen={onCategoryManagerOpen}
mediaItems={mediaItems}
localMediaItems={localMediaItems}
/>

View File

@ -17,6 +17,7 @@ interface CompactFilmStripProps {
onDeletePicture: () => void;
onGalleryPickerOpen: (index: number) => void;
cacheBustKeys: Record<string, number>;
thumbnailLayout?: 'strip' | 'grid';
}
export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
@ -29,25 +30,32 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
isOwner,
onDeletePicture,
onGalleryPickerOpen,
cacheBustKeys
cacheBustKeys,
thumbnailLayout = 'strip'
}) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// Wheel scroll support for horizontal scrolling
// Wheel scroll support for horizontal scrolling (strip) and vertical scrolling (grid)
useEffect(() => {
const container = scrollContainerRef.current;
if (container) {
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault();
container.scrollLeft += e.deltaY;
if (thumbnailLayout === 'grid') {
// Grid mode: vertical scrolling
container.scrollTop += e.deltaY;
} else {
// Strip mode: horizontal scrolling
container.scrollLeft += e.deltaY;
}
}
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}
}, []);
}, [thumbnailLayout]);
const handleDragStart = (e: React.DragEvent, index: number) => {
e.stopPropagation();
@ -133,11 +141,14 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
}
return (
<div className="bg-muted/80 backdrop-blur-sm p-2 landscape:p-1 lg:landscape:p-2 border-t flex-shrink-0 landscape:h-20 lg:landscape:h-44 hidden lg:block landscape:block">
<div className="flex items-center justify-between mb-1 landscape:hidden lg:landscape:flex">
<span className="text-foreground text-xs font-medium"><T>Gallery</T> ({groupedItems.length})</span>
</div>
<div className="flex gap-3 overflow-x-auto scrollbar-hide landscape:h-16 lg:landscape:h-36" ref={scrollContainerRef}>
<div className="p-0 mt-2 landscape:p-0 lg:landscape:p-0 hidden lg:block landscape:block w-auto max-w-full">
<div
className={thumbnailLayout === 'grid'
? "flex flex-wrap gap-1 justify-center max-h-[200px] overflow-y-auto scrollbar-hide"
: "flex gap-1 overflow-x-auto scrollbar-hide justify-center"
}
ref={scrollContainerRef}
>
{groupedItems.map((item, index) => (
<div
key={item.renderKey || item.id}
@ -145,7 +156,7 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
onDragStart={(e) => isEditMode && handleDragStart(e, index)}
onDragOver={(e) => isEditMode && handleDragOver(e, index)}
onDrop={(e) => isEditMode && handleDrop(e, index)}
className={`relative landscape:mx-1 landscape:py-0 lg:landscape:m-2 flex-shrink-0 landscape:w-16 landscape:h-16 lg:landscape:w-32 lg:landscape:h-32 overflow-hidden cursor-pointer border-2 transition-all ${item.id === mediaItem?.id
className={`relative landscape:mx-1 landscape:py-0 lg:landscape:m-2 flex-shrink-0 w-24 h-24 landscape:w-16 landscape:h-16 lg:landscape:w-32 lg:landscape:h-32 overflow-hidden cursor-pointer transition-all ${item.id === mediaItem?.id
? 'shadow-lg scale-105'
: 'hover:scale-102'
}`}

View File

@ -24,6 +24,7 @@ interface CompactMediaViewerProps {
isOwner: boolean;
videoPlaybackUrl?: string;
videoPosterUrl?: string;
imageFit?: 'contain' | 'cover';
}
export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
@ -40,7 +41,8 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
navigate,
isOwner,
videoPlaybackUrl,
videoPosterUrl
videoPosterUrl,
imageFit = 'cover'
}) => {
const playerRef = useRef<MediaPlayerInstance>(null);
const [externalVideoState, setExternalVideoState] = React.useState<Record<string, boolean>>({});
@ -103,26 +105,12 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
}, [showDesktopLayout, currentVersions, currentVersionIndex, mediaItem]);
return (
<div className="absolute inset-0 flex items-center justify-center">
{/* Mobile Landscape Back Button Overlay */}
<div className="absolute top-4 left-4 z-50 lg:hidden landscape:block hidden">
<Button
variant="secondary"
size="icon"
className="rounded-full bg-black/50 text-white hover:bg-black/70 backdrop-blur-sm"
onClick={() => {
playerRef.current?.pause();
navigate(-1);
}}
>
<ArrowLeft className="h-6 w-6" />
</Button>
</div>
return (
<div className="absolute inset-0 flex items-center justify-center px-1">
{showDesktopLayout ? (
isVideo ? (
mediaItem.type === 'tiktok' ? (
mediaItem.mediaType === 'tiktok' ? (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={mediaItem.image_url}
@ -150,7 +138,7 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
)
) : (
(() => {
if (mediaItem.type === 'page-external') {
if (mediaItem.mediaType === 'page-external') {
const url = (mediaItem.meta as any)?.url || mediaItem.image_url;
const ytId = getYouTubeVideoId(url);
@ -200,7 +188,7 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
sizes="(max-width: 1024px) 100vw, 1200px"
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-black/50 rounded-full p-4 group-hover:bg-black/70 transition-colors">
<div className="bg-black/50 p-4 group-hover:bg-black/70 transition-colors">
<Play className="w-12 h-12 text-white fill-white" />
</div>
</div>
@ -209,11 +197,13 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
}
}
return (
<ResponsiveImage
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
alt={mediaItem.title}
imgClassName="w-full h-full object-contain cursor-pointer select-none"
imgClassName={`w-full h-full object-${imageFit} cursor-pointer select-none`}
onClick={() => onExpand(mediaItem)}
title="Double-tap to view fullscreen"
sizes="(max-width: 1024px) 100vw, 1200px"

View File

@ -1,6 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { LayoutGrid, StretchHorizontal, Edit3, MoreVertical, Trash2, Save, X, Grid } from 'lucide-react';
import { LayoutGrid, StretchHorizontal, Edit3, MoreVertical, Trash2, Save, X, Grid, FolderTree } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@ -27,6 +27,7 @@ interface CompactPostHeaderProps {
onEditPost: () => void;
onDeletePicture: () => void;
onDeletePost: () => void;
onCategoryManagerOpen?: () => void;
mediaItems: PostMediaItem[];
localMediaItems?: PostMediaItem[];
}
@ -46,6 +47,7 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
onEditPost,
onDeletePicture,
onDeletePost,
onCategoryManagerOpen,
mediaItems,
localMediaItems
}) => {
@ -72,6 +74,30 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
<div className="p-3 border-b">
{post.title && post.title !== mediaItem?.title && (<h1 className="text-lg font-bold mb-1">{post.title}</h1>)}
{post.description && <MarkdownRenderer content={post.description} className="prose-sm text-sm text-foreground/90 mb-2" />}
{/* Category Breadcrumbs */}
{(() => {
const displayPaths = (post as any).category_paths || [];
if (displayPaths.length === 0) return null;
return (
<div className="flex flex-col gap-1 mt-2">
{displayPaths.map((path: any[], pathIdx: number) => (
<div key={pathIdx} className="flex items-center gap-2 text-sm text-muted-foreground">
<FolderTree className="h-4 w-4 shrink-0" />
{path.map((cat: any, idx: number) => (
<span key={cat.id} className="flex items-center">
{idx > 0 && <span className="mx-1 text-muted-foreground/50">/</span>}
<a href={`/categories/${cat.slug}`} className="hover:text-primary transition-colors hover:underline">
{cat.name}
</a>
</span>
))}
</div>
))}
</div>
);
})()}
</div>
)
)}
@ -151,6 +177,7 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEditPost}><Edit3 className="mr-2 h-4 w-4" /><span>Edit Post Wizard</span></DropdownMenuItem>
{onCategoryManagerOpen && <DropdownMenuItem onClick={onCategoryManagerOpen}><FolderTree className="mr-2 h-4 w-4" /><span>Manage Categories</span></DropdownMenuItem>}
<DropdownMenuItem onClick={onDeletePicture} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete this picture</span></DropdownMenuItem>
<DropdownMenuItem onClick={onDeletePost} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete whole post</span></DropdownMenuItem>
</DropdownMenuContent>

View File

@ -0,0 +1,157 @@
import React, { useState } from "react";
import { PostMediaItem } from "../../types";
import { CompactFilmStrip } from "./CompactFilmStrip";
import { CompactMediaViewer } from "./CompactMediaViewer";
import { isVideoType, normalizeMediaType, detectMediaType } from "@/lib/mediaRegistry";
export interface GalleryProps {
/** Array of media items to display */
mediaItems: PostMediaItem[];
/** Currently selected media item */
selectedItem: PostMediaItem;
/** Callback when a media item is selected */
onMediaSelect: (item: PostMediaItem) => void;
/** Callback when media is expanded/fullscreen */
onExpand: (item: PostMediaItem) => void;
/** Whether the current user owns this content */
isOwner: boolean;
/** Edit mode state */
isEditMode?: boolean;
/** Local media items for edit mode */
localMediaItems?: PostMediaItem[];
/** Setter for local media items */
setLocalMediaItems?: (items: PostMediaItem[]) => void;
/** Callback to delete current picture */
onDeletePicture?: () => void;
/** Callback to open gallery picker at specific index */
onGalleryPickerOpen?: (index: number) => void;
/** Cache bust keys for images */
cacheBustKeys?: Record<string, number>;
/** Navigation data for cross-post navigation */
navigationData?: any;
/** Handler for navigating between posts */
handleNavigate?: (direction: 'next' | 'prev') => void;
/** React Router navigate function */
navigate?: (path: any) => void;
/** Video playback URL (for Mux videos) */
videoPlaybackUrl?: string;
/** Video poster/thumbnail URL */
videoPosterUrl?: string;
/** Whether to show desktop layout */
showDesktopLayout?: boolean;
/** Thumbnail layout mode: 'strip' (horizontal scroll) or 'grid' (multiple rows) */
thumbnailLayout?: 'strip' | 'grid';
/** Image fit mode: 'contain' (fit within bounds) or 'cover' (fill bounds) */
imageFit?: 'contain' | 'cover';
/** Custom className for container */
className?: string;
}
/**
* Standalone Gallery Component
*
* Displays a main media viewer with a filmstrip of thumbnails below.
* Supports images, videos (Mux, YouTube, TikTok), and version control.
*
* @example
* ```tsx
* <Gallery
* mediaItems={pictures}
* selectedItem={currentPicture}
* onMediaSelect={setPicture}
* onExpand={handleFullscreen}
* isOwner={true}
* />
* ```
*/
export const Gallery: React.FC<GalleryProps> = ({
mediaItems,
selectedItem,
onMediaSelect,
onExpand,
isOwner,
isEditMode = false,
localMediaItems,
setLocalMediaItems,
onDeletePicture = () => { },
onGalleryPickerOpen = () => { },
cacheBustKeys = {},
navigationData,
handleNavigate = () => { },
navigate = () => { },
videoPlaybackUrl,
videoPosterUrl,
showDesktopLayout = true,
thumbnailLayout = 'strip',
imageFit = 'cover',
className = ""
}) => {
const currentImageIndex = mediaItems.findIndex(item => item.id === selectedItem.id);
const effectiveType = selectedItem.mediaType || detectMediaType(selectedItem.image_url);
const isVideo = isVideoType(normalizeMediaType(effectiveType));
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Main Media Viewer - takes remaining space */}
<div className="flex-1 relative w-full min-h-0">
<CompactMediaViewer
mediaItem={selectedItem}
isVideo={isVideo}
showDesktopLayout={showDesktopLayout}
mediaItems={mediaItems}
currentImageIndex={currentImageIndex}
navigationData={navigationData}
handleNavigate={handleNavigate}
onMediaSelect={onMediaSelect}
onExpand={onExpand}
cacheBustKeys={cacheBustKeys}
navigate={navigate}
isOwner={isOwner}
videoPlaybackUrl={videoPlaybackUrl}
videoPosterUrl={videoPosterUrl}
imageFit={imageFit}
/>
</div>
{/* Filmstrip - centered to match main image */}
<div className="flex justify-center w-full">
<CompactFilmStrip
mediaItems={mediaItems}
localMediaItems={localMediaItems}
setLocalMediaItems={setLocalMediaItems}
isEditMode={isEditMode}
mediaItem={selectedItem}
onMediaSelect={onMediaSelect}
isOwner={isOwner}
onDeletePicture={onDeletePicture}
onGalleryPickerOpen={onGalleryPickerOpen}
cacheBustKeys={cacheBustKeys}
thumbnailLayout={thumbnailLayout}
/>
</div>
</div>
);
};

View File

@ -1,17 +1,11 @@
import { User } from '@supabase/supabase-js';
import { ImageFile, MediaType, MediaItem as GlobalMediaItem } from "@/types";
import { ImageFile, MediaItem } from "@/types";
// Local Media Item definition extensions
// We extend GlobalMediaItem to ensure compatibility with shared components/utils
export interface PostMediaItem extends GlobalMediaItem {
// PostMediaItem extends MediaItem with post-specific fields
export type PostMediaItem = MediaItem & {
post_id: string | null;
position: number;
renderKey?: string;
// likes_count in Global is number | null. Locally we prefer number (default 0).
// We can keep it compliant or override if we are careful.
// Let's keep it compatible by not overriding unless necessary.
is_liked?: boolean;
}
};
export interface PostItem {
id: string;
@ -21,10 +15,25 @@ export interface PostItem {
created_at: string;
updated_at: string;
pictures?: PostMediaItem[];
settings?: any;
isPseudo?: boolean;
type?: string;
meta?: any;
settings?: PostSettings;
meta?: PostMeta;
isPseudo?: boolean; // For single pictures without posts
type?: string; // Deprecated: use pictures[0].mediaType instead
}
// Structured settings instead of `any`
export interface PostSettings {
display?: 'compact' | 'article' | 'thumbs';
link?: string; // For link posts
image_url?: string;
thumbnail_url?: string;
[key: string]: any; // Allow extension
}
// Structured meta instead of `any`
export interface PostMeta {
slug?: string; // For page-intern
[key: string]: any; // Allow extension
}
export interface UserProfile {
@ -76,6 +85,8 @@ export interface PostRendererProps {
onMediaSelect: (item: PostMediaItem) => void;
onExpand: (item: PostMediaItem) => void;
onDownload: () => void;
onCategoryManagerOpen?: () => void;
// Comparison helpers
currentImageIndex: number;

View File

@ -32,6 +32,7 @@ export const usePostActions = ({
const [showTikTokDialog, setShowTikTokDialog] = useState(false);
const [showGalleryPicker, setShowGalleryPicker] = useState(false);
const [showAIWizard, setShowAIWizard] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
// --- Actions ---
@ -152,6 +153,23 @@ export const usePostActions = ({
return;
};
const handleMetaUpdate = async (newMeta: any) => {
if (!post) return;
// Persist to database
try {
const { updatePostMeta } = await import('@/lib/db');
await updatePostMeta(post.id, newMeta);
toast.success(translate('Categories updated'));
// Trigger parent refresh
fetchMedia();
} catch (error) {
console.error('Failed to update post meta:', error);
toast.error(translate('Failed to update categories'));
}
};
return {
// States
@ -161,11 +179,13 @@ export const usePostActions = ({
showTikTokDialog, setShowTikTokDialog,
showGalleryPicker, setShowGalleryPicker,
showAIWizard, setShowAIWizard,
showCategoryManager, setShowCategoryManager,
// Actions
handleDeletePost,
handleDeletePicture,
handleUnlinkImage,
handleMetaUpdate,
// handleLike // Kept in Post.tsx for now due to state coupling
};
};

View File

@ -598,6 +598,7 @@ const UserPage = ({ userId: propUserId, slug: propSlug, embedded = false, initia
isEditMode={isEditMode}
onToggleEditMode={() => setIsEditMode(!isEditMode)}
onPageUpdate={handlePageUpdate}
onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)}
className="ml-auto"
/>
</div>

View File

@ -99,21 +99,13 @@ const UserProfile = () => {
}, [feedPosts]);
const fetchUserProfile = async () => {
console.log('fetchUserProfile', userId);
try {
// Fetch profile with user_roles
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select(`
*,
user_roles (role),
user_organizations (
role,
organizations (
id,
name,
slug
)
)
user_roles (role)
`)
.eq('user_id', userId)
.maybeSingle();
@ -140,22 +132,32 @@ const UserProfile = () => {
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
// Fetch user organizations separately (no direct FK from profiles to user_organizations)
if (userId) {
const { data: userOrgs, error: orgsError } = await supabase
.from('user_organizations')
.select(`
role,
organizations (
id,
name,
slug
)
`)
.eq('user_id', userId);
setOrganizations(orgs);
if (orgsError) {
console.error('Error fetching organizations:', orgsError);
} else {
const orgs = (userOrgs || []).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
// 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
setOrganizations(orgs);
}
} else {
setOrganizations([]);
}

View File

@ -7,18 +7,137 @@ export interface ImageFile {
description?: string;
}
// Media Type Registry
export type MediaType = 'supabase-image' | 'mux-video' | 'youtube' | 'tiktok' | 'video-intern' | 'page-intern' | 'page-external' | null;
// ============================================================================
// MEDIA TYPES - Single source of truth
// ============================================================================
// Unified Media Item (from 'pictures' table)
export interface MediaItem {
// Media type identifiers
export const MEDIA_TYPES = {
SUPABASE_IMAGE: 'supabase-image',
MUX_VIDEO: 'mux-video',
VIDEO_INTERN: 'video-intern',
YOUTUBE: 'youtube',
TIKTOK: 'tiktok',
PAGE: 'page-intern',
PAGE_EXTERNAL: 'page-external',
} as const;
export type MediaType = typeof MEDIA_TYPES[keyof typeof MEDIA_TYPES] | null;
// ============================================================================
// DISCRIMINATED UNION TYPES FOR MEDIA ITEMS
// ============================================================================
// Base interface for common fields across all media types
interface BaseMediaItem {
id: string;
user_id: string;
title: string;
description: string | null;
created_at: string;
updated_at: string;
likes_count: number;
is_liked?: boolean;
is_selected: boolean;
visible: boolean;
tags: string[] | null;
flags: string[] | null;
organization_id: string | null;
parent_id: string | null; // For versions/variants
position: number;
picture_id?: string; // For feed items that reference a picture
author?: {
username: string;
display_name: string;
avatar_url: string;
};
}
// Responsive image data structure
export interface ResponsiveImageData {
sources?: Array<{
srcset: string;
type: string;
sizes?: string;
}>;
fallback?: string;
}
// Internal video metadata
export interface VideoInternMetadata {
duration?: number;
width?: number;
height?: number;
format?: string;
size?: number;
}
// Image media item
export interface ImageMediaItem extends BaseMediaItem {
mediaType: 'supabase-image';
image_url: string;
thumbnail_url: string | null;
responsive?: ResponsiveImageData;
job?: any; // Image processing job metadata
meta?: any; // Additional metadata
}
// Video media item (Mux or internal)
export interface VideoMediaItem extends BaseMediaItem {
mediaType: 'mux-video' | 'video-intern';
video_url: string;
image_url: string; // For backward compatibility
thumbnail_url: string;
duration?: number;
meta: MuxVideoMetadata | VideoInternMetadata | null;
}
// YouTube media item
export interface YouTubeMediaItem extends BaseMediaItem {
mediaType: 'youtube';
videoId: string;
image_url: string; // Embed URL
thumbnail_url: string;
meta: { url: string;[key: string]: any };
}
// TikTok media item
export interface TikTokMediaItem extends BaseMediaItem {
mediaType: 'tiktok';
videoId: string;
image_url: string; // Embed URL
thumbnail_url: string;
meta: { url: string;[key: string]: any };
}
// Page media item (internal or external)
export interface PageMediaItem extends BaseMediaItem {
mediaType: 'page-intern' | 'page-external';
slug?: string; // For internal pages
url?: string; // For external pages
image_url: string; // Preview/thumbnail image
thumbnail_url?: string;
meta: { url?: string; slug?: string;[key: string]: any };
}
// Discriminated union of all media item types
export type MediaItem =
| ImageMediaItem
| VideoMediaItem
| YouTubeMediaItem
| TikTokMediaItem
| PageMediaItem;
// Legacy MediaItem interface - kept for backward compatibility during migration
// TODO: Remove after all components are updated to use discriminated unions
export interface LegacyMediaItem {
id: string;
user_id: string;
title: string;
description: string | null;
image_url: string; // For images: image URL, for videos: HLS stream URL
thumbnail_url: string | null;
type: MediaType; // NULL or 'supabase-image' for images, 'mux-video' for videos
type: import('./lib/mediaRegistry').MediaType; // NULL or 'supabase-image' for images, 'mux-video' for videos
meta: any | null; // Video metadata for mux-video, null for images
created_at: string;
updated_at: string;