db | cache | kats:) | gallery | widgets
This commit is contained in:
parent
a5625856c0
commit
34fe0f1690
@ -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 />} />
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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" */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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 (
|
||||
|
||||
291
packages/ui/src/components/admin/BansManager.tsx
Normal file
291
packages/ui/src/components/admin/BansManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
200
packages/ui/src/components/admin/ViolationsMonitor.tsx
Normal file
200
packages/ui/src/components/admin/ViolationsMonitor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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 || {})
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
@ -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">
|
||||
|
||||
229
packages/ui/src/components/widgets/GalleryWidget.tsx
Normal file
229
packages/ui/src/components/widgets/GalleryWidget.tsx
Normal 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;
|
||||
246
packages/ui/src/components/widgets/PageCardWidget.tsx
Normal file
246
packages/ui/src/components/widgets/PageCardWidget.tsx
Normal 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;
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
126
packages/ui/src/hooks/useLayouts.ts
Normal file
126
packages/ui/src/hooks/useLayouts.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
175
packages/ui/src/pages/Post/adapters.ts
Normal file
175
packages/ui/src/pages/Post/adapters.ts
Normal 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);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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'
|
||||
}`}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
157
packages/ui/src/pages/Post/renderers/components/Gallery.tsx
Normal file
157
packages/ui/src/pages/Post/renderers/components/Gallery.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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([]);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user