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/new" element={<NewCollection />} />
|
||||||
<Route path="/collections/:userId/:slug" element={<Collections />} />
|
<Route path="/collections/:userId/:slug" element={<Collections />} />
|
||||||
<Route path="/tags/:tag" element={<TagPage />} />
|
<Route path="/tags/:tag" element={<TagPage />} />
|
||||||
|
<Route path="/categories/:slug" element={<Index />} />
|
||||||
<Route path="/search" element={<SearchResults />} />
|
<Route path="/search" element={<SearchResults />} />
|
||||||
<Route path="/wizard" element={<Wizard />} />
|
<Route path="/wizard" element={<Wizard />} />
|
||||||
<Route path="/new" element={<NewPost />} />
|
<Route path="/new" element={<NewPost />} />
|
||||||
@ -114,6 +115,7 @@ const AppWrapper = () => {
|
|||||||
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
|
<Route path="/org/:orgSlug/collections/new" element={<NewCollection />} />
|
||||||
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
|
<Route path="/org/:orgSlug/collections/:userId/:slug" element={<Collections />} />
|
||||||
<Route path="/org/:orgSlug/tags/:tag" element={<TagPage />} />
|
<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/search" element={<SearchResults />} />
|
||||||
<Route path="/org/:orgSlug/wizard" element={<Wizard />} />
|
<Route path="/org/:orgSlug/wizard" element={<Wizard />} />
|
||||||
<Route path="/org/:orgSlug/new" element={<NewPost />} />
|
<Route path="/org/:orgSlug/new" element={<NewPost />} />
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
import { MediaGrid, PhotoGrid } from "./PhotoGrid";
|
|
||||||
import MediaCard from "./MediaCard";
|
import MediaCard from "./MediaCard";
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useProfiles } from "@/contexts/ProfilesContext";
|
|
||||||
import { usePostNavigation } from "@/hooks/usePostNavigation";
|
import { usePostNavigation } from "@/hooks/usePostNavigation";
|
||||||
import { useOrganization } from "@/contexts/OrganizationContext";
|
import { useOrganization } from "@/contexts/OrganizationContext";
|
||||||
import { useFeedData } from "@/hooks/useFeedData";
|
import { useFeedData } from "@/hooks/useFeedData";
|
||||||
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
|
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
|
||||||
import { UserProfile } from '../pages/Post/types';
|
|
||||||
import * as db from '../pages/Post/db';
|
import * as db from '../pages/Post/db';
|
||||||
import type { MediaItem, MediaType } from "@/types";
|
import type { MediaItem } from "@/types";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
// Duplicate types for now or we could reuse specific generic props
|
// Duplicate types for now or we could reuse specific generic props
|
||||||
@ -24,6 +21,7 @@ interface GalleryLargeProps {
|
|||||||
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
|
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
|
||||||
navigationSourceId?: string;
|
navigationSourceId?: string;
|
||||||
sortBy?: FeedSortOption;
|
sortBy?: FeedSortOption;
|
||||||
|
categorySlugs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const GalleryLarge = ({
|
const GalleryLarge = ({
|
||||||
@ -31,7 +29,8 @@ const GalleryLarge = ({
|
|||||||
customLoading,
|
customLoading,
|
||||||
navigationSource = 'home',
|
navigationSource = 'home',
|
||||||
navigationSourceId,
|
navigationSourceId,
|
||||||
sortBy = 'latest'
|
sortBy = 'latest',
|
||||||
|
categorySlugs
|
||||||
}: GalleryLargeProps) => {
|
}: GalleryLargeProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -48,6 +47,7 @@ const GalleryLarge = ({
|
|||||||
isOrgContext,
|
isOrgContext,
|
||||||
orgSlug,
|
orgSlug,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
categorySlugs,
|
||||||
// Disable hook if we have custom pictures
|
// Disable hook if we have custom pictures
|
||||||
enabled: !customPictures
|
enabled: !customPictures
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface ListLayoutProps {
|
|||||||
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
|
navigationSource?: 'home' | 'collection' | 'tag' | 'user';
|
||||||
navigationSourceId?: string;
|
navigationSourceId?: string;
|
||||||
isOwner?: boolean; // Not strictly used for rendering list but good for consistency
|
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 }) => {
|
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 = ({
|
export const ListLayout = ({
|
||||||
sortBy = 'latest',
|
sortBy = 'latest',
|
||||||
navigationSource = 'home',
|
navigationSource = 'home',
|
||||||
navigationSourceId
|
navigationSourceId,
|
||||||
|
categorySlugs
|
||||||
}: ListLayoutProps) => {
|
}: ListLayoutProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@ -114,7 +116,8 @@ export const ListLayout = ({
|
|||||||
sourceId: navigationSourceId,
|
sourceId: navigationSourceId,
|
||||||
isOrgContext,
|
isOrgContext,
|
||||||
orgSlug,
|
orgSlug,
|
||||||
sortBy
|
sortBy,
|
||||||
|
categorySlugs
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('posts', feedPosts);
|
// console.log('posts', feedPosts);
|
||||||
|
|||||||
@ -36,6 +36,7 @@ interface PageActionsProps {
|
|||||||
onToggleEditMode?: () => void;
|
onToggleEditMode?: () => void;
|
||||||
onPageUpdate: (updatedPage: Page) => void;
|
onPageUpdate: (updatedPage: Page) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
onMetaUpdated?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
}
|
}
|
||||||
@ -47,6 +48,7 @@ export const PageActions = ({
|
|||||||
onToggleEditMode,
|
onToggleEditMode,
|
||||||
onPageUpdate,
|
onPageUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMetaUpdated,
|
||||||
className,
|
className,
|
||||||
showLabels = true
|
showLabels = true
|
||||||
}: PageActionsProps) => {
|
}: PageActionsProps) => {
|
||||||
@ -109,18 +111,24 @@ export const PageActions = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetaUpdate = (newMeta: any) => {
|
const handleMetaUpdate = async (newMeta: any) => {
|
||||||
// PageActions locally updates the page object.
|
// Update local state immediately for responsive UI
|
||||||
// Ideally we should reload the page via UserPage but this gives instant feedback.
|
|
||||||
onPageUpdate({ ...page, meta: newMeta });
|
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.
|
// Persist to database
|
||||||
// Looking at CategoryManager usage, it likely saves.
|
try {
|
||||||
// We might want to pass invalidatePageCache to it or call it here if we know it saved.
|
const { updatePageMeta } = await import('@/lib/db');
|
||||||
// Use timeout to debounce invalidation? For now assume CategoryManager handles its own saving/invalidation or we rely on page refresh.
|
await updatePageMeta(page.id, newMeta);
|
||||||
// Actually, CategoryManager props has "onPageMetaUpdate", which updates local state.
|
invalidatePageCache();
|
||||||
// If CategoryManager saves to DB, it should invalidate.
|
|
||||||
// Let's stick to the handlers we control here.
|
// 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) => {
|
const handleToggleVisibility = async (e?: React.MouseEvent) => {
|
||||||
@ -516,6 +524,8 @@ draft: ${!page.visible}
|
|||||||
currentPageId={page.id}
|
currentPageId={page.id}
|
||||||
currentPageMeta={page.meta}
|
currentPageMeta={page.meta}
|
||||||
onPageMetaUpdate={handleMetaUpdate}
|
onPageMetaUpdate={handleMetaUpdate}
|
||||||
|
filterByType="pages"
|
||||||
|
defaultMetaType="pages"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Legacy/Standard Parent Picker - Keeping relevant as "Page Hierarchy" vs "Category Taxonomy" */}
|
{/* 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';
|
variant?: 'grid' | 'feed';
|
||||||
responsive?: any;
|
responsive?: any;
|
||||||
showContent?: boolean;
|
showContent?: boolean;
|
||||||
|
showHeader?: boolean;
|
||||||
|
overlayMode?: 'hover' | 'always';
|
||||||
authorAvatarUrl?: string | null;
|
authorAvatarUrl?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
@ -35,6 +37,8 @@ const PageCard: React.FC<PageCardProps> = ({
|
|||||||
variant = 'grid',
|
variant = 'grid',
|
||||||
responsive,
|
responsive,
|
||||||
showContent = true,
|
showContent = true,
|
||||||
|
showHeader = true,
|
||||||
|
overlayMode = 'hover',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
versionCount
|
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"
|
className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full border rounded-lg mb-4"
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b flex items-center justify-between">
|
{showHeader && (
|
||||||
<UserAvatarBlock
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
userId={authorId}
|
<UserAvatarBlock
|
||||||
avatarUrl={authorAvatarUrl}
|
userId={authorId}
|
||||||
displayName={author}
|
avatarUrl={authorAvatarUrl}
|
||||||
className="w-8 h-8"
|
displayName={author}
|
||||||
showDate={true}
|
className="w-8 h-8"
|
||||||
createdAt={created_at}
|
showDate={true}
|
||||||
/>
|
createdAt={created_at}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`relative w-full ${tikTokId ? 'aspect-[9/16]' : 'aspect-[16/9]'} overflow-hidden bg-muted`}>
|
<div className={`relative w-full ${tikTokId ? 'aspect-[9/16]' : 'aspect-[16/9]'} overflow-hidden bg-muted`}>
|
||||||
{isPlaying && isExternalVideo ? (
|
{isPlaying && isExternalVideo ? (
|
||||||
@ -126,28 +132,30 @@ const PageCard: React.FC<PageCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-2">
|
{showContent && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="p-4 space-y-2">
|
||||||
<h3 className="text-xl font-semibold">{title}</h3>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<h3 className="text-xl font-semibold">{title}</h3>
|
||||||
|
|
||||||
{description && (
|
|
||||||
<div className="text-sm text-foreground/90 line-clamp-3">
|
|
||||||
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-2">
|
{description && (
|
||||||
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
|
<div className="text-sm text-foreground/90 line-clamp-3">
|
||||||
<Heart className={`h-5 w-5 ${isLiked ? "fill-red-500 text-red-500" : ""}`} />
|
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
|
||||||
<span>{likes}</span>
|
</div>
|
||||||
</Button>
|
)}
|
||||||
<Button size="sm" variant="ghost" className="px-0 gap-1">
|
|
||||||
<MessageCircle className="h-5 w-5" />
|
<div className="flex items-center gap-4 pt-2">
|
||||||
<span>{comments}</span>
|
<Button size="sm" variant="ghost" className="px-0 gap-1" onClick={handleLike}>
|
||||||
</Button>
|
<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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -208,7 +216,7 @@ const PageCard: React.FC<PageCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showContent && (
|
{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="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 justify-between mb-2">
|
||||||
<UserAvatarBlock
|
<UserAvatarBlock
|
||||||
|
|||||||
@ -35,6 +35,8 @@ interface PhotoCardProps {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
authorAvatarUrl?: string | null;
|
authorAvatarUrl?: string | null;
|
||||||
showContent?: boolean;
|
showContent?: boolean;
|
||||||
|
showHeader?: boolean;
|
||||||
|
overlayMode?: 'hover' | 'always';
|
||||||
responsive?: any;
|
responsive?: any;
|
||||||
variant?: 'grid' | 'feed';
|
variant?: 'grid' | 'feed';
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
@ -59,6 +61,8 @@ const PhotoCard = ({
|
|||||||
createdAt,
|
createdAt,
|
||||||
authorAvatarUrl,
|
authorAvatarUrl,
|
||||||
showContent = true,
|
showContent = true,
|
||||||
|
showHeader = true,
|
||||||
|
overlayMode = 'hover',
|
||||||
responsive,
|
responsive,
|
||||||
variant = 'grid',
|
variant = 'grid',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
@ -409,18 +413,20 @@ const PhotoCard = ({
|
|||||||
|
|
||||||
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
|
{/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */}
|
||||||
{showContent && variant === 'grid' && (
|
{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="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 justify-between mb-2">
|
||||||
<div className="flex items-center space-x-2">
|
{showHeader && (
|
||||||
<UserAvatarBlock
|
<div className="flex items-center space-x-2">
|
||||||
userId={authorId}
|
<UserAvatarBlock
|
||||||
avatarUrl={authorAvatarUrl}
|
userId={authorId}
|
||||||
displayName={author}
|
avatarUrl={authorAvatarUrl}
|
||||||
hoverStyle={true}
|
displayName={author}
|
||||||
showDate={false}
|
hoverStyle={true}
|
||||||
/>
|
showDate={false}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { useLayoutEffect } from "react";
|
|||||||
import { useFeedData } from "@/hooks/useFeedData";
|
import { useFeedData } from "@/hooks/useFeedData";
|
||||||
|
|
||||||
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
|
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
|
||||||
import { UploadCloud, Maximize } from "lucide-react";
|
import { UploadCloud, Maximize, FolderTree } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { MediaType } from "@/types";
|
import type { MediaType } from "@/types";
|
||||||
|
|
||||||
@ -51,6 +51,7 @@ interface MediaGridProps {
|
|||||||
sortBy?: FeedSortOption;
|
sortBy?: FeedSortOption;
|
||||||
supabaseClient?: any;
|
supabaseClient?: any;
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
|
categorySlugs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaGrid = ({
|
const MediaGrid = ({
|
||||||
@ -63,7 +64,8 @@ const MediaGrid = ({
|
|||||||
showVideos = true,
|
showVideos = true,
|
||||||
sortBy = 'latest',
|
sortBy = 'latest',
|
||||||
supabaseClient,
|
supabaseClient,
|
||||||
apiUrl
|
apiUrl,
|
||||||
|
categorySlugs
|
||||||
}: MediaGridProps) => {
|
}: MediaGridProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
// Use provided client or fallback to default
|
// Use provided client or fallback to default
|
||||||
@ -95,6 +97,7 @@ const MediaGrid = ({
|
|||||||
isOrgContext,
|
isOrgContext,
|
||||||
orgSlug,
|
orgSlug,
|
||||||
sortBy,
|
sortBy,
|
||||||
|
categorySlugs,
|
||||||
// Disable hook if we have custom pictures
|
// Disable hook if we have custom pictures
|
||||||
enabled: !customPictures,
|
enabled: !customPictures,
|
||||||
supabaseClient
|
supabaseClient
|
||||||
@ -182,8 +185,6 @@ const MediaGrid = ({
|
|||||||
hasRestoredScroll.current = false;
|
hasRestoredScroll.current = false;
|
||||||
}, [cacheKey]);
|
}, [cacheKey]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Track scroll position
|
// Track scroll position
|
||||||
const lastScrollY = useRef(window.scrollY);
|
const lastScrollY = useRef(window.scrollY);
|
||||||
|
|
||||||
@ -374,6 +375,94 @@ const MediaGrid = ({
|
|||||||
}
|
}
|
||||||
}, [mediaItems, customPictures, navigationSource, navigationSourceId, setNavigationData]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="py-8">
|
<div className="py-8">
|
||||||
@ -426,79 +515,91 @@ const MediaGrid = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<>
|
||||||
{mediaItems.map((item, index) => {
|
{groupedItems.sections.map((section) => (
|
||||||
const itemType = normalizeMediaType(item.type);
|
<div key={section.key} className="mb-8">
|
||||||
const isVideo = isVideoType(itemType);
|
{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
|
// For images, convert URL to optimized format
|
||||||
const displayUrl = item.image_url;
|
const displayUrl = item.image_url;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="relative group">
|
<div key={item.id} className="relative group">
|
||||||
<MediaCard
|
<MediaCard
|
||||||
id={item.id}
|
id={item.id}
|
||||||
pictureId={item.picture_id}
|
pictureId={item.picture_id}
|
||||||
url={displayUrl}
|
url={displayUrl}
|
||||||
thumbnailUrl={item.thumbnail_url}
|
thumbnailUrl={item.thumbnail_url}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
// Pass blank/undefined so UserAvatarBlock uses context data
|
// Pass blank/undefined so UserAvatarBlock uses context data
|
||||||
author={undefined as any}
|
author={undefined as any}
|
||||||
authorAvatarUrl={undefined}
|
authorAvatarUrl={undefined}
|
||||||
authorId={item.user_id}
|
authorId={item.user_id}
|
||||||
likes={item.likes_count || 0}
|
likes={item.likes_count || 0}
|
||||||
comments={item.comments[0]?.count || 0}
|
comments={item.comments[0]?.count || 0}
|
||||||
isLiked={userLikes.has(item.picture_id || item.id)}
|
isLiked={userLikes.has(item.picture_id || item.id)}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
type={itemType}
|
type={itemType}
|
||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onClick={() => handleMediaClick(item.id, itemType, index)}
|
onClick={() => handleMediaClick(item.id, itemType, index)}
|
||||||
onLike={fetchUserLikes}
|
onLike={fetchUserLikes}
|
||||||
onDelete={fetchMediaFromPicturesTable}
|
onDelete={fetchMediaFromPicturesTable}
|
||||||
onEdit={handleEditPost}
|
onEdit={handleEditPost}
|
||||||
created_at={item.created_at}
|
created_at={item.created_at}
|
||||||
job={item.job}
|
job={item.job}
|
||||||
responsive={item.responsive}
|
responsive={item.responsive}
|
||||||
apiUrl={apiUrl}
|
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">
|
<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" />
|
<Maximize className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaCard
|
<MediaCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
pictureId={item.picture_id}
|
pictureId={item.picture_id}
|
||||||
url={displayUrl}
|
url={displayUrl}
|
||||||
thumbnailUrl={item.thumbnail_url}
|
thumbnailUrl={item.thumbnail_url}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
author={undefined as any}
|
author={undefined as any}
|
||||||
authorAvatarUrl={undefined}
|
authorAvatarUrl={undefined}
|
||||||
authorId={item.user_id}
|
authorId={item.user_id}
|
||||||
likes={item.likes_count || 0}
|
likes={item.likes_count || 0}
|
||||||
comments={item.comments[0]?.count || 0}
|
comments={item?.comments?.[0]?.count || 0}
|
||||||
isLiked={userLikes.has(item.picture_id || item.id)}
|
isLiked={userLikes.has(item.picture_id || item.id)}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
type={itemType}
|
type={itemType}
|
||||||
meta={item.meta}
|
meta={item.meta}
|
||||||
onClick={() => handleMediaClick(item.id, itemType, index)}
|
onClick={() => handleMediaClick(item.id, itemType, index)}
|
||||||
onLike={fetchUserLikes}
|
onLike={fetchUserLikes}
|
||||||
onDelete={fetchMediaFromPicturesTable}
|
onDelete={fetchMediaFromPicturesTable}
|
||||||
onEdit={handleEditPost}
|
onEdit={handleEditPost}
|
||||||
|
|
||||||
created_at={item.created_at}
|
created_at={item.created_at}
|
||||||
job={item.job}
|
job={item.job}
|
||||||
responsive={item.responsive}
|
responsive={item.responsive}
|
||||||
apiUrl={apiUrl}
|
apiUrl={apiUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading Indicator / Observer Target */}
|
{/* Loading Indicator / Observer Target */}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
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 { Home, User, Upload, LogOut, LogIn, Camera, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity } from "lucide-react";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { useLog } from "@/contexts/LogContext";
|
|
||||||
import { useWizardContext } from "@/hooks/useWizardContext";
|
import { useWizardContext } from "@/hooks/useWizardContext";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -30,8 +29,7 @@ const TopNavigation = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const currentLang = getCurrentLang();
|
const currentLang = getCurrentLang();
|
||||||
const { isLoggerVisible, setLoggerVisible } = useLog();
|
const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
|
||||||
const { clearWizardImage, creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
|
|
||||||
|
|
||||||
const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth';
|
const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth';
|
||||||
|
|
||||||
@ -42,12 +40,9 @@ const TopNavigation = () => {
|
|||||||
}, [user?.id, fetchProfile]);
|
}, [user?.id, fetchProfile]);
|
||||||
|
|
||||||
const userProfile = user ? profiles[user.id] : null;
|
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;
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
// ... (rest of component until link)
|
|
||||||
|
|
||||||
{/* Profile Grid Button - Direct to profile feed */ }
|
{/* Profile Grid Button - Direct to profile feed */ }
|
||||||
{
|
{
|
||||||
user && (
|
user && (
|
||||||
|
|||||||
@ -10,9 +10,9 @@ import {
|
|||||||
useSidebar
|
useSidebar
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { T, translate } from "@/i18n";
|
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 = ({
|
export const AdminSidebar = ({
|
||||||
activeSection,
|
activeSection,
|
||||||
@ -28,6 +28,8 @@ export const AdminSidebar = ({
|
|||||||
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
|
{ id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard },
|
||||||
{ id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users },
|
{ id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users },
|
||||||
{ id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server },
|
{ 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 (
|
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;
|
sourceId?: string;
|
||||||
onNavigate?: (id: string) => void;
|
onNavigate?: (id: string) => void;
|
||||||
sortBy?: FeedSortOption;
|
sortBy?: FeedSortOption;
|
||||||
|
categorySlugs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRELOAD_BUFFER = 3;
|
const PRELOAD_BUFFER = 3;
|
||||||
@ -23,7 +24,8 @@ export const MobileFeed: React.FC<MobileFeedProps> = ({
|
|||||||
source = 'home',
|
source = 'home',
|
||||||
sourceId,
|
sourceId,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
sortBy = 'latest'
|
sortBy = 'latest',
|
||||||
|
categorySlugs
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@ -34,7 +36,8 @@ export const MobileFeed: React.FC<MobileFeedProps> = ({
|
|||||||
const { posts, loading, error, hasMore } = useFeedData({
|
const { posts, loading, error, hasMore } = useFeedData({
|
||||||
source,
|
source,
|
||||||
sourceId,
|
sourceId,
|
||||||
sortBy
|
sortBy,
|
||||||
|
categorySlugs
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scroll Restoration Logic
|
// Scroll Restoration Logic
|
||||||
|
|||||||
@ -563,9 +563,9 @@ const WidgetItem: React.FC<WidgetItemProps> = ({
|
|||||||
{/* Widget Content - With selection wrapper */}
|
{/* Widget Content - With selection wrapper */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
// 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 ? "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",
|
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?
|
// 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 { Button } from '@/components/ui/button';
|
||||||
import { T } from '@/i18n';
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -11,7 +13,9 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 {
|
interface PlaygroundHeaderProps {
|
||||||
viewMode: 'design' | 'preview';
|
viewMode: 'design' | 'preview';
|
||||||
@ -21,9 +25,12 @@ interface PlaygroundHeaderProps {
|
|||||||
htmlSize?: number;
|
htmlSize?: number;
|
||||||
|
|
||||||
// Template Menu
|
// Template Menu
|
||||||
templates: ILayoutTemplate[];
|
templates: Layout[];
|
||||||
handleLoadTemplate: (template: ILayoutTemplate) => void;
|
handleLoadTemplate: (template: Layout) => void;
|
||||||
onSaveTemplateClick: () => 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
|
// Other Actions
|
||||||
onPasteJsonClick: () => void;
|
onPasteJsonClick: () => void;
|
||||||
@ -44,12 +51,39 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
|
|||||||
templates,
|
templates,
|
||||||
handleLoadTemplate,
|
handleLoadTemplate,
|
||||||
onSaveTemplateClick,
|
onSaveTemplateClick,
|
||||||
|
handleDeleteTemplate,
|
||||||
|
handleToggleVisibility,
|
||||||
|
handleRenameLayout,
|
||||||
onPasteJsonClick,
|
onPasteJsonClick,
|
||||||
handleDumpJson,
|
handleDumpJson,
|
||||||
handleLoadContext,
|
handleLoadContext,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
setIsEditMode
|
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 (
|
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 className="border-b bg-background/95 backdrop-blur z-10 shrink-0 p-4 flex flex-wrap gap-4 justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
@ -92,7 +126,7 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
|
|||||||
<DropdownMenuContent className="w-56">
|
<DropdownMenuContent className="w-56">
|
||||||
<DropdownMenuLabel>Predefined Layouts</DropdownMenuLabel>
|
<DropdownMenuLabel>Predefined Layouts</DropdownMenuLabel>
|
||||||
<DropdownMenuGroup>
|
<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)}>
|
<DropdownMenuItem key={`pre-${i}`} onClick={() => handleLoadTemplate(t)}>
|
||||||
{t.name}
|
{t.name}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -101,13 +135,108 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>My Layouts</DropdownMenuLabel>
|
<DropdownMenuLabel>My Layouts</DropdownMenuLabel>
|
||||||
<DropdownMenuGroup>
|
<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>
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">No saved layouts</div>
|
||||||
)}
|
)}
|
||||||
{templates.filter(t => !t.isPredefined).map((t, i) => (
|
{templates.filter(t => !t.is_predefined).map((t, i) => (
|
||||||
<DropdownMenuItem key={`cust-${i}`} onClick={() => handleLoadTemplate(t)}>
|
<div key={`cust-${i}`} className="flex items-center gap-1 px-1">
|
||||||
{t.name}
|
{editingLayoutId === t.id ? (
|
||||||
</DropdownMenuItem>
|
<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>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@ -150,8 +279,8 @@ export const PlaygroundHeader: React.FC<PlaygroundHeaderProps> = ({
|
|||||||
|
|
||||||
{htmlSize !== undefined && (
|
{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' :
|
<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' :
|
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'
|
'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">
|
}`} title="Gmail clips HTML larger than 102KB">
|
||||||
<span className="font-mono">{(htmlSize / 1024).toFixed(1)}KB</span>
|
<span className="font-mono">{(htmlSize / 1024).toFixed(1)}KB</span>
|
||||||
<span className="opacity-50">/ 102KB</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>
|
<T>{isEditMode ? 'Disable Edit Mode' : 'Enable Edit Mode'}</T>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -146,15 +146,12 @@ export const FieldTemplate = (props: any) => {
|
|||||||
{formattedLabel && (
|
{formattedLabel && (
|
||||||
<label
|
<label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className="block text-sm font-medium mb-1"
|
className="block text-sm font-medium mb-1"
|
||||||
>
|
>
|
||||||
{formattedLabel}
|
{formattedLabel}
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{description && (
|
|
||||||
<div className="text-sm text-gray-500 mb-2">{description}</div>
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
{errors && errors.length > 0 && (
|
{errors && errors.length > 0 && (
|
||||||
<div id={`${id}-error`} className="mt-1 text-sm text-red-600">
|
<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
|
// Custom ObjectFieldTemplate with Grouping Support
|
||||||
export const ObjectFieldTemplate = (props: any) => {
|
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
|
// Group properties based on uiSchema
|
||||||
const groups: Record<string, any[]> = {};
|
const groups: Record<string, any[]> = {};
|
||||||
@ -196,10 +196,10 @@ export const ObjectFieldTemplate = (props: any) => {
|
|||||||
if (!hasGroups) {
|
if (!hasGroups) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{props.description && (
|
{description && (typeof description !== 'string' || description.trim()) && (
|
||||||
<p className="text-sm text-gray-600 mb-4">{props.description}</p>
|
<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) => (
|
{properties.map((element: any) => (
|
||||||
<div key={element.name} className="w-full">
|
<div key={element.name} className="w-full">
|
||||||
{element.content}
|
{element.content}
|
||||||
|
|||||||
@ -498,18 +498,23 @@ export const TypeBuilder: React.FC<{
|
|||||||
setActiveDragItem(null);
|
setActiveDragItem(null);
|
||||||
|
|
||||||
if (over && over.id === 'canvas') {
|
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
|
// Generate robust ID to avoid collisions
|
||||||
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
const newItemId = `field-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
|
||||||
// Map JSON Schema type names to database primitive type names
|
// Determine the refId for this element
|
||||||
const typeNameMap: Record<string, string> = {
|
// If template already has refId (custom type from palette), use it
|
||||||
'number': 'int', 'boolean': 'bool', 'string': 'string', 'array': 'array', 'object': 'object'
|
// Otherwise, look up primitive type by mapped name
|
||||||
};
|
let refId = (template as any).refId;
|
||||||
const dbTypeName = typeNameMap[template.type] || template.type;
|
if (!refId) {
|
||||||
|
// Map JSON Schema type names to database primitive type names
|
||||||
// Look up the type ID from availableTypes
|
const typeNameMap: Record<string, string> = {
|
||||||
const typeDefinition = availableTypes.find(t => t.name === dbTypeName);
|
'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 = {
|
const newItem: BuilderElement = {
|
||||||
id: newItemId,
|
id: newItemId,
|
||||||
@ -518,7 +523,7 @@ export const TypeBuilder: React.FC<{
|
|||||||
title: template.name,
|
title: template.name,
|
||||||
description: '',
|
description: '',
|
||||||
uiSchema: {},
|
uiSchema: {},
|
||||||
...(typeDefinition && { refId: typeDefinition.id } as any) // Store the type ID
|
...(refId && { refId } as any) // Store the type ID
|
||||||
};
|
};
|
||||||
|
|
||||||
setElements(prev => [...prev, newItem]);
|
setElements(prev => [...prev, newItem]);
|
||||||
|
|||||||
@ -50,52 +50,107 @@ export const TypeRenderer: React.FC<TypeRendererProps> = ({
|
|||||||
'alias': { type: 'string' }
|
'alias': { type: 'string' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// For structures, generate JSON schema from structure_fields
|
// Recursive function to generate schema for any type (primitive or structure)
|
||||||
if (editedType.kind === 'structure' && editedType.structure_fields && editedType.structure_fields.length > 0) {
|
const generateSchemaForType = (typeId: string, visited = new Set<string>()): any => {
|
||||||
const properties: Record<string, any> = {};
|
// Prevent infinite recursion for circular references
|
||||||
const required: string[] = [];
|
if (visited.has(typeId)) {
|
||||||
|
return { type: 'object', description: 'Circular reference detected' };
|
||||||
|
}
|
||||||
|
|
||||||
editedType.structure_fields.forEach(field => {
|
const type = types.find(t => t.id === typeId);
|
||||||
// Find the field type to get its parent type
|
if (!type) return { type: 'string' };
|
||||||
const fieldType = types.find(type => type.id === field.field_type_id);
|
|
||||||
if (fieldType && fieldType.parent_type_id) {
|
// If it's a primitive, return the JSON schema mapping
|
||||||
// Find the parent type (primitive)
|
if (type.kind === 'primitive') {
|
||||||
const parentType = types.find(type => type.id === fieldType.parent_type_id);
|
return primitiveToJsonSchema[type.name] || { type: 'string' };
|
||||||
if (parentType) {
|
}
|
||||||
// Use the primitive mapping to get the JSON Schema type
|
|
||||||
const jsonSchemaType = primitiveToJsonSchema[parentType.name] || { type: 'string' };
|
// If it's a structure, recursively build its schema
|
||||||
properties[field.field_name] = {
|
if (type.kind === 'structure' && type.structure_fields) {
|
||||||
...jsonSchemaType,
|
visited.add(typeId);
|
||||||
title: field.field_name,
|
const properties: Record<string, any> = {};
|
||||||
...(fieldType.description && { description: fieldType.description })
|
const required: string[] = [];
|
||||||
};
|
|
||||||
if (field.required) {
|
type.structure_fields.forEach(field => {
|
||||||
required.push(field.field_name);
|
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 = {
|
return {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties,
|
properties,
|
||||||
...(required.length > 0 && { required })
|
...(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));
|
setJsonSchemaString(JSON.stringify(generatedSchema, null, 2));
|
||||||
|
|
||||||
// Also aggregate UI schema from fields
|
// Recursive function to generate UI schema for a type
|
||||||
const aggregatedUiSchema: Record<string, any> = {};
|
const generateUiSchemaForType = (typeId: string, visited = new Set<string>()): any => {
|
||||||
editedType.structure_fields.forEach(field => {
|
if (visited.has(typeId)) return {};
|
||||||
const fieldType = types.find(type => type.id === field.field_type_id);
|
|
||||||
if (fieldType && fieldType.meta?.uiSchema) {
|
|
||||||
aggregatedUiSchema[field.field_name] = fieldType.meta.uiSchema;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 = {
|
const finalUiSchema = {
|
||||||
...aggregatedUiSchema,
|
...generatedUiSchema,
|
||||||
...(editedType.meta?.uiSchema || {})
|
...(editedType.meta?.uiSchema || {})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -95,11 +95,14 @@ const TypesPlayground: React.FC = () => {
|
|||||||
// Find or create the field type
|
// Find or create the field type
|
||||||
let fieldType = types.find(t => t.name === `${editedType.name}.${el.name}` && t.kind === 'field');
|
let fieldType = types.find(t => t.name === `${editedType.name}.${el.name}` && t.kind === 'field');
|
||||||
|
|
||||||
// Find the primitive type for this field
|
// Find the parent type for this field (could be primitive or custom)
|
||||||
const primitiveType = types.find(t => t.kind === 'primitive' && t.name === el.type);
|
// 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) {
|
if (!parentType) {
|
||||||
console.error(`Primitive type not found: ${el.type}`);
|
console.error(`Parent type not found: ${el.type} (refId: ${(el as any).refId})`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +110,7 @@ const TypesPlayground: React.FC = () => {
|
|||||||
name: `${editedType.name}.${el.name}`,
|
name: `${editedType.name}.${el.name}`,
|
||||||
kind: 'field' as const,
|
kind: 'field' as const,
|
||||||
description: el.description || `Field ${el.name}`,
|
description: el.description || `Field ${el.name}`,
|
||||||
parent_type_id: primitiveType.id,
|
parent_type_id: parentType.id,
|
||||||
meta: {}
|
meta: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -159,16 +162,20 @@ const TypesPlayground: React.FC = () => {
|
|||||||
// For structures, create field types first
|
// For structures, create field types first
|
||||||
if (output.mode === 'structure') {
|
if (output.mode === 'structure') {
|
||||||
const fieldTypes = await Promise.all(output.elements.map(async (el) => {
|
const fieldTypes = await Promise.all(output.elements.map(async (el) => {
|
||||||
const primitiveType = types.find(t => t.kind === 'primitive' && t.name === el.type);
|
// Find the parent type (could be primitive or custom)
|
||||||
if (!primitiveType) {
|
const parentType = (el as any).refId
|
||||||
throw new Error(`Primitive type not found: ${el.type}`);
|
? 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({
|
return await createType({
|
||||||
name: `${output.name}.${el.name}`,
|
name: `${output.name}.${el.name}`,
|
||||||
kind: 'field',
|
kind: 'field',
|
||||||
description: el.description || `Field ${el.name}`,
|
description: el.description || `Field ${el.name}`,
|
||||||
parent_type_id: primitiveType.id,
|
parent_type_id: parentType.id,
|
||||||
meta: {}
|
meta: {}
|
||||||
} as any);
|
} 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 { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { T } from "@/i18n";
|
import { T } from "@/i18n";
|
||||||
|
|
||||||
interface CategoryManagerProps {
|
interface CategoryManagerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentPageId?: string; // If provided, allows linking page to category
|
currentPageId?: string; // If provided, allows linking page to category
|
||||||
currentPageMeta?: any;
|
currentPageMeta?: any;
|
||||||
onPageMetaUpdate?: (newMeta: any) => void;
|
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 [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
@ -31,6 +32,8 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [creationParentId, setCreationParentId] = useState<string | null>(null);
|
const [creationParentId, setCreationParentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Initial linked category from page meta
|
// Initial linked category from page meta
|
||||||
const getLinkedCategoryIds = (): string[] => {
|
const getLinkedCategoryIds = (): string[] => {
|
||||||
if (!currentPageMeta) return [];
|
if (!currentPageMeta) return [];
|
||||||
@ -51,7 +54,16 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await fetchCategories({ includeChildren: true });
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Failed to load categories");
|
toast.error("Failed to load categories");
|
||||||
@ -85,11 +97,22 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
await createCategory({
|
// Apply default meta type if provided
|
||||||
|
const categoryData: any = {
|
||||||
...editingCategory,
|
...editingCategory,
|
||||||
parentId: creationParentId || undefined,
|
parentId: creationParentId || undefined,
|
||||||
relationType: 'generalization'
|
relationType: 'generalization'
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Set meta.type if defaultMetaType is provided
|
||||||
|
if (defaultMetaType) {
|
||||||
|
categoryData.meta = {
|
||||||
|
...(categoryData.meta || {}),
|
||||||
|
type: defaultMetaType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await createCategory(categoryData);
|
||||||
toast.success("Category created");
|
toast.success("Category created");
|
||||||
} else if (editingCategory.id) {
|
} else if (editingCategory.id) {
|
||||||
await updateCategory(editingCategory.id, editingCategory);
|
await updateCategory(editingCategory.id, editingCategory);
|
||||||
@ -132,15 +155,19 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const newIds = [...currentIds, selectedCategoryId];
|
const newIds = [...currentIds, selectedCategoryId];
|
||||||
// Clear legacy single ID if it exists to clean up, but prioritize setting array
|
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
||||||
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
|
||||||
toast.success("Page added to category");
|
// Use callback if provided, otherwise fall back to updatePageMeta
|
||||||
if (onPageMetaUpdate) {
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Failed to link page");
|
toast.error("Failed to link");
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@ -155,14 +182,19 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const newIds = currentIds.filter(id => id !== selectedCategoryId);
|
const newIds = currentIds.filter(id => id !== selectedCategoryId);
|
||||||
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
|
const newMeta = { ...currentPageMeta, categoryIds: newIds, categoryId: null };
|
||||||
toast.success("Page removed from category");
|
|
||||||
|
// Use callback if provided, otherwise fall back to updatePageMeta
|
||||||
if (onPageMetaUpdate) {
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Failed to unlink page");
|
toast.error("Failed to unlink");
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
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
|
isLinked && !isSelected && "bg-primary/5" // Slight highlight for linked items not selected
|
||||||
)}
|
)}
|
||||||
style={{ marginLeft: `${level * 16}px` }}
|
style={{ marginLeft: `${level * 16}px` }}
|
||||||
onClick={() => setSelectedCategoryId(cat.id)}
|
onClick={() => {
|
||||||
|
setSelectedCategoryId(cat.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isLinked ? (
|
{isLinked ? (
|
||||||
@ -206,7 +240,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -221,9 +258,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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 */}
|
{/* 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">
|
<div className="flex justify-between items-center mb-2 px-2">
|
||||||
<span className="text-sm font-semibold text-muted-foreground">Category Hierarchy</span>
|
<span className="text-sm font-semibold text-muted-foreground">Category Hierarchy</span>
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
|
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
|
||||||
@ -242,7 +279,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Editor or Actions */}
|
{/* 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 ? (
|
{editingCategory ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<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 React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -7,6 +6,7 @@ import { T, translate } from '@/i18n';
|
|||||||
import { Search, FileText, Check } from 'lucide-react';
|
import { Search, FileText, Check } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { fetchUserPages } from '@/lib/db';
|
||||||
|
|
||||||
interface PagePickerDialogProps {
|
interface PagePickerDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -23,6 +23,7 @@ interface Page {
|
|||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
meta?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
|
export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
|
||||||
@ -51,13 +52,7 @@ export const PagePickerDialog: React.FC<PagePickerDialogProps> = ({
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const data = await fetchUserPages(user.id);
|
||||||
.from('pages')
|
|
||||||
.select('id, title, slug, is_public, visible, updated_at')
|
|
||||||
.eq('owner', user.id)
|
|
||||||
.order('title', { ascending: true });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
setPages(data || []);
|
setPages(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching pages:', error);
|
console.error('Error fetching pages:', error);
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
interface PhotoCardWidgetProps {
|
interface PhotoCardWidgetProps {
|
||||||
isEditMode?: boolean;
|
isEditMode?: boolean;
|
||||||
pictureId?: string | null;
|
pictureId?: string | null;
|
||||||
|
showHeader?: boolean;
|
||||||
|
showFooter?: boolean;
|
||||||
|
contentDisplay?: 'below' | 'overlay' | 'overlay-always';
|
||||||
// Widget instance management
|
// Widget instance management
|
||||||
widgetInstanceId?: string;
|
widgetInstanceId?: string;
|
||||||
onPropsChange?: (props: Record<string, any>) => void;
|
onPropsChange?: (props: Record<string, any>) => void;
|
||||||
@ -32,6 +35,9 @@ interface UserProfile {
|
|||||||
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
||||||
isEditMode = false,
|
isEditMode = false,
|
||||||
pictureId: propPictureId = null,
|
pictureId: propPictureId = null,
|
||||||
|
showHeader = true,
|
||||||
|
showFooter = true,
|
||||||
|
contentDisplay = 'below',
|
||||||
onPropsChange
|
onPropsChange
|
||||||
}) => {
|
}) => {
|
||||||
const [pictureId, setPictureId] = useState<string | null>(propPictureId);
|
const [pictureId, setPictureId] = useState<string | null>(propPictureId);
|
||||||
@ -57,6 +63,15 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
const fetchPictureData = async () => {
|
const fetchPictureData = async () => {
|
||||||
if (!pictureId) return;
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch picture
|
// Fetch picture
|
||||||
@ -166,9 +181,14 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card border rounded-lg p-8 text-center">
|
<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" />
|
<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>
|
<T>Picture not found</T>
|
||||||
</p>
|
</p>
|
||||||
|
{isEditMode && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<T>Please select a new picture using the image picker</T>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -193,7 +213,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
|
|||||||
window.location.href = `/post/${id}`;
|
window.location.href = `/post/${id}`;
|
||||||
}}
|
}}
|
||||||
onLike={handleLike}
|
onLike={handleLike}
|
||||||
variant="feed"
|
variant={contentDisplay === 'overlay' || contentDisplay === 'overlay-always' ? 'grid' : 'feed'}
|
||||||
|
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
|
||||||
|
showHeader={showHeader}
|
||||||
|
showContent={showFooter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import PhotoGrid, { MediaItemType } from '@/components/PhotoGrid';
|
import PhotoGrid, { MediaItemType } from '@/components/PhotoGrid';
|
||||||
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
|
||||||
import { T } from '@/i18n';
|
import { T } from '@/i18n';
|
||||||
import { ImageIcon, Plus, Grid } from 'lucide-react';
|
import { ImageIcon, Plus, Grid } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MediaType } from '@/lib/mediaRegistry';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
interface PhotoGridWidgetProps {
|
interface PhotoGridWidgetProps {
|
||||||
@ -54,38 +52,16 @@ const PhotoGridWidget: React.FC<PhotoGridWidgetProps> = ({ isEditMode = false, p
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { fetchMediaItemsByIds } = await import('@/lib/db');
|
||||||
.from('pictures')
|
const items = await fetchMediaItemsByIds(pictureIds, { maintainOrder: true });
|
||||||
.select('*')
|
|
||||||
.in('id', pictureIds)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
// Transform to format expected by PhotoGrid (add comments count)
|
||||||
|
const gridItems = items.map(item => ({
|
||||||
// Transform to MediaItem format expected by PhotoGrid
|
...item,
|
||||||
const items = (data || []).map(pic => ({
|
comments: [{ count: 0 }] // Mock comments count
|
||||||
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
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Maintain order of selection if possible, otherwise by created_at
|
setMediaItems(gridItems as any[]);
|
||||||
// To maintain selection order:
|
|
||||||
const orderedItems = pictureIds
|
|
||||||
.map(id => items.find(item => item.id === id))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
setMediaItems(orderedItems as any[]);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching grid media:', error);
|
console.error('Error fetching grid media:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -296,7 +296,7 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
|
|||||||
setImagePickerField(null);
|
setImagePickerField(null);
|
||||||
}}
|
}}
|
||||||
onSelectPicture={(picture) => {
|
onSelectPicture={(picture) => {
|
||||||
updateSetting(imagePickerField, picture.image_url);
|
updateSetting(imagePickerField, picture.id);
|
||||||
setImagePickerOpen(false);
|
setImagePickerOpen(false);
|
||||||
setImagePickerField(null);
|
setImagePickerField(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,14 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { T } from '@/i18n';
|
import { T } from '@/i18n';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { WidgetDefinition } from '@/lib/widgetRegistry';
|
import { WidgetDefinition } from '@/lib/widgetRegistry';
|
||||||
import { ImagePickerDialog } from './ImagePickerDialog';
|
|
||||||
import { Image as ImageIcon } from 'lucide-react';
|
|
||||||
import { WidgetPropertiesForm } from './WidgetPropertiesForm';
|
import { WidgetPropertiesForm } from './WidgetPropertiesForm';
|
||||||
|
|
||||||
interface WidgetSettingsManagerProps {
|
interface WidgetSettingsManagerProps {
|
||||||
|
|||||||
@ -157,8 +157,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
const { error } = await supabase.auth.signOut();
|
const { error } = await supabase.auth.signOut();
|
||||||
|
|
||||||
debugger
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast({
|
toast({
|
||||||
title: "Sign out failed",
|
title: "Sign out failed",
|
||||||
|
|||||||
@ -17,6 +17,8 @@ interface UseFeedDataProps {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
sortBy?: FeedSortOption;
|
sortBy?: FeedSortOption;
|
||||||
supabaseClient?: any; // Using any to avoid importing SupabaseClient type if not strictly needed here, or better import it
|
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 = ({
|
export const useFeedData = ({
|
||||||
@ -26,10 +28,14 @@ export const useFeedData = ({
|
|||||||
orgSlug,
|
orgSlug,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
sortBy = 'latest',
|
sortBy = 'latest',
|
||||||
supabaseClient
|
supabaseClient,
|
||||||
|
categoryIds,
|
||||||
|
categorySlugs
|
||||||
}: UseFeedDataProps) => {
|
}: UseFeedDataProps) => {
|
||||||
const { getCache, saveCache } = useFeedCache();
|
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
|
// Initialize from cache if available
|
||||||
const [posts, setPosts] = useState<FeedPost[]>(() => {
|
const [posts, setPosts] = useState<FeedPost[]>(() => {
|
||||||
@ -127,6 +133,8 @@ export const useFeedData = ({
|
|||||||
let queryParams = `?page=${currentPage}&limit=${FEED_PAGE_SIZE}&sortBy=${sortBy}`;
|
let queryParams = `?page=${currentPage}&limit=${FEED_PAGE_SIZE}&sortBy=${sortBy}`;
|
||||||
if (source) queryParams += `&source=${source}`;
|
if (source) queryParams += `&source=${source}`;
|
||||||
if (sourceId) queryParams += `&sourceId=${sourceId}`;
|
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?
|
// If we have token, pass it?
|
||||||
// The Supabase client in the hook prop (supabaseClient) or defaultSupabase usually has the session.
|
// The Supabase client in the hook prop (supabaseClient) or defaultSupabase usually has the session.
|
||||||
@ -198,7 +206,7 @@ export const useFeedData = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsFetchingMore(false);
|
setIsFetchingMore(false);
|
||||||
}
|
}
|
||||||
}, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, supabaseClient]);
|
}, [source, sourceId, isOrgContext, orgSlug, enabled, fetchProfiles, sortBy, supabaseClient, categoryIds, categorySlugs]);
|
||||||
|
|
||||||
// Initial Load
|
// Initial Load
|
||||||
useEffect(() => {
|
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 { useState, useEffect } from 'react';
|
||||||
import { useLayout } from '@/contexts/LayoutContext';
|
import { useLayout } from '@/contexts/LayoutContext';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { LayoutTemplateManager, LayoutTemplate as ILayoutTemplate } from '@/lib/layoutTemplates';
|
|
||||||
import { useWidgetLoader } from './useWidgetLoader.tsx';
|
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() {
|
export function usePlaygroundLogic() {
|
||||||
// UI State
|
// UI State
|
||||||
@ -26,23 +30,38 @@ export function usePlaygroundLogic() {
|
|||||||
loadPageLayout
|
loadPageLayout
|
||||||
} = useLayout();
|
} = useLayout();
|
||||||
|
|
||||||
// Template State
|
// Template State (now from Supabase)
|
||||||
const [templates, setTemplates] = useState<ILayoutTemplate[]>([]);
|
const [templates, setTemplates] = useState<Layout[]>([]);
|
||||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
const [newTemplateName, setNewTemplateName] = useState('');
|
const [newTemplateName, setNewTemplateName] = useState('');
|
||||||
|
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||||
|
|
||||||
// Paste JSON State
|
// Paste JSON State
|
||||||
const [isPasteDialogOpen, setIsPasteDialogOpen] = useState(false);
|
const [isPasteDialogOpen, setIsPasteDialogOpen] = useState(false);
|
||||||
const [pasteJsonContent, setPasteJsonContent] = useState('');
|
const [pasteJsonContent, setPasteJsonContent] = useState('');
|
||||||
|
|
||||||
const { loadWidgetBundle } = useWidgetLoader();
|
const { loadWidgetBundle } = useWidgetLoader();
|
||||||
|
const { getLayouts, createLayout, updateLayout, deleteLayout } = useLayouts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshTemplates = () => {
|
const refreshTemplates = async () => {
|
||||||
setTemplates(LayoutTemplateManager.getTemplates());
|
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
|
// Initialization Effect
|
||||||
@ -155,32 +174,49 @@ export function usePlaygroundLogic() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadTemplate = async (template: ILayoutTemplate) => {
|
const handleLoadTemplate = async (template: Layout) => {
|
||||||
try {
|
try {
|
||||||
await importPageLayout(pageId, template.layoutJson);
|
// layout_json is already a parsed object, convert to string for importPageLayout
|
||||||
toast.success(`Loaded template: ${template.name}`);
|
const layoutJsonString = JSON.stringify(template.layout_json);
|
||||||
|
await importPageLayout(pageId, layoutJsonString);
|
||||||
|
toast.success(`Loaded layout: ${template.name}`);
|
||||||
setLayoutJson(null);
|
setLayoutJson(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load template", e);
|
console.error("Failed to load layout", e);
|
||||||
toast.error("Failed to load template");
|
toast.error("Failed to load layout");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveTemplate = async () => {
|
const handleSaveTemplate = async () => {
|
||||||
if (!newTemplateName.trim()) {
|
if (!newTemplateName.trim()) {
|
||||||
toast.error("Please enter a template name");
|
toast.error("Please enter a layout name");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const json = await exportPageLayout(pageId);
|
const json = await exportPageLayout(pageId);
|
||||||
LayoutTemplateManager.saveTemplate(newTemplateName.trim(), json);
|
const layoutObject = JSON.parse(json);
|
||||||
toast.success("Template saved locally");
|
|
||||||
|
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);
|
setIsSaveDialogOpen(false);
|
||||||
setNewTemplateName('');
|
setNewTemplateName('');
|
||||||
refreshTemplates();
|
await refreshTemplates();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save template", e);
|
console.error("Failed to save layout", e);
|
||||||
toast.error("Failed to save template");
|
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 handleLoadContext = async () => {
|
||||||
const bundleUrl = '/widgets/email/library.json';
|
const bundleUrl = '/widgets/email/library.json';
|
||||||
try {
|
try {
|
||||||
@ -327,6 +429,7 @@ export function usePlaygroundLogic() {
|
|||||||
pageName,
|
pageName,
|
||||||
layoutJson,
|
layoutJson,
|
||||||
templates,
|
templates,
|
||||||
|
isLoadingTemplates,
|
||||||
isSaveDialogOpen, setIsSaveDialogOpen,
|
isSaveDialogOpen, setIsSaveDialogOpen,
|
||||||
newTemplateName, setNewTemplateName,
|
newTemplateName, setNewTemplateName,
|
||||||
isPasteDialogOpen, setIsPasteDialogOpen,
|
isPasteDialogOpen, setIsPasteDialogOpen,
|
||||||
@ -336,6 +439,9 @@ export function usePlaygroundLogic() {
|
|||||||
handleDumpJson,
|
handleDumpJson,
|
||||||
handleLoadTemplate,
|
handleLoadTemplate,
|
||||||
handleSaveTemplate,
|
handleSaveTemplate,
|
||||||
|
handleDeleteTemplate,
|
||||||
|
handleToggleVisibility,
|
||||||
|
handleRenameLayout,
|
||||||
handlePasteJson,
|
handlePasteJson,
|
||||||
handleLoadContext,
|
handleLoadContext,
|
||||||
handleExportHtml,
|
handleExportHtml,
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export type Database = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
description: string | null
|
description: string | null
|
||||||
id: string
|
id: string
|
||||||
|
meta: Json | null
|
||||||
name: string
|
name: string
|
||||||
owner_id: string | null
|
owner_id: string | null
|
||||||
slug: string
|
slug: string
|
||||||
@ -54,6 +55,7 @@ export type Database = {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
meta?: Json | null
|
||||||
name: string
|
name: string
|
||||||
owner_id?: string | null
|
owner_id?: string | null
|
||||||
slug: string
|
slug: string
|
||||||
@ -64,6 +66,7 @@ export type Database = {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
meta?: Json | null
|
||||||
name?: string
|
name?: string
|
||||||
owner_id?: string | null
|
owner_id?: string | null
|
||||||
slug?: string
|
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: {
|
likes: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
@ -1231,7 +1273,14 @@ export type Database = {
|
|||||||
| "other"
|
| "other"
|
||||||
category_visibility: "public" | "unlisted" | "private"
|
category_visibility: "public" | "unlisted" | "private"
|
||||||
collaborator_role: "viewer" | "editor" | "owner"
|
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"
|
type_visibility: "public" | "private" | "custom"
|
||||||
}
|
}
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
@ -1390,7 +1439,8 @@ export const Constants = {
|
|||||||
],
|
],
|
||||||
category_visibility: ["public", "unlisted", "private"],
|
category_visibility: ["public", "unlisted", "private"],
|
||||||
collaborator_role: ["viewer", "editor", "owner"],
|
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"],
|
type_visibility: ["public", "private", "custom"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface FeedPost {
|
|||||||
author?: UserProfile;
|
author?: UserProfile;
|
||||||
settings?: any;
|
settings?: any;
|
||||||
is_liked?: boolean;
|
is_liked?: boolean;
|
||||||
|
category_paths?: any[][]; // Array of category paths (each path is root -> leaf)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestCache = new Map<string, Promise<any>>();
|
const requestCache = new Map<string, Promise<any>>();
|
||||||
@ -100,22 +101,14 @@ export const invalidateCache = (key: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchPostById = async (id: string, client?: SupabaseClient) => {
|
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 () => {
|
return fetchWithDeduplication(`post-${id}`, async () => {
|
||||||
const { data, error } = await supabase
|
const { fetchPostDetailsAPI } = await import('@/pages/Post/db');
|
||||||
.from('posts')
|
const data = await fetchPostDetailsAPI(id);
|
||||||
.select(`
|
if (!data) return null;
|
||||||
*,
|
|
||||||
pictures (
|
|
||||||
*
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('id', id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
return data;
|
||||||
});
|
}, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchPictureById = async (id: string, client?: SupabaseClient) => {
|
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) => {
|
export const fetchPageById = async (id: string, client?: SupabaseClient) => {
|
||||||
const supabase = client || defaultSupabase;
|
const supabase = client || defaultSupabase;
|
||||||
return fetchWithDeduplication(`page-${id}`, async () => {
|
return fetchWithDeduplication(`page-${id}`, async () => {
|
||||||
@ -398,6 +428,45 @@ export const fetchUserPage = async (userId: string, slug: string, client?: Supab
|
|||||||
}, 10);
|
}, 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) => {
|
export const invalidateUserPageCache = (userId: string, slug: string) => {
|
||||||
const key = `user-page-${userId}-${slug}`;
|
const key = `user-page-${userId}-${slug}`;
|
||||||
invalidateCache(key);
|
invalidateCache(key);
|
||||||
@ -844,7 +913,7 @@ export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | '
|
|||||||
description: post.description,
|
description: post.description,
|
||||||
image_url: cover.image_url,
|
image_url: cover.image_url,
|
||||||
thumbnail_url: cover.thumbnail_url,
|
thumbnail_url: cover.thumbnail_url,
|
||||||
type: cover.type as MediaType,
|
type: cover.mediaType as MediaType,
|
||||||
meta: cover.meta,
|
meta: cover.meta,
|
||||||
created_at: post.created_at,
|
created_at: post.created_at,
|
||||||
user_id: post.user_id,
|
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
|
// Using Supabase client directly since this interacts with 'pages' table
|
||||||
const { data: page, error: fetchError } = await defaultSupabase
|
const { data: page, error: fetchError } = await defaultSupabase
|
||||||
.from('pages')
|
.from('pages')
|
||||||
.select('meta')
|
.select('meta, owner, slug')
|
||||||
.eq('id', pageId)
|
.eq('id', pageId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -979,5 +1048,69 @@ export const updatePageMeta = async (pageId: string, metaUpdates: any) => {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Invalidate caches
|
||||||
|
invalidateCache(`page-details-${pageId}`);
|
||||||
|
if (page.owner && page.slug) {
|
||||||
|
invalidateUserPageCache(page.owner, page.slug);
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
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
|
* Media Type Registry
|
||||||
*
|
*
|
||||||
* Unified system for handling different media types in the 'pictures' table
|
* 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)
|
* This file contains utility functions and the renderer registry.
|
||||||
* - 'mux-video' => Mux-powered videos (MuxVideoCard)
|
* Type definitions are in @/types.ts
|
||||||
* - 'youtube' => YouTube videos
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
MEDIA_TYPES,
|
||||||
|
MediaType,
|
||||||
|
MediaItem,
|
||||||
|
ImageMediaItem,
|
||||||
|
VideoMediaItem,
|
||||||
|
YouTubeMediaItem,
|
||||||
|
TikTokMediaItem,
|
||||||
|
PageMediaItem,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
// Media type identifiers
|
// Re-export for convenience
|
||||||
export const MEDIA_TYPES = {
|
export {
|
||||||
SUPABASE_IMAGE: 'supabase-image',
|
MEDIA_TYPES,
|
||||||
MUX_VIDEO: 'mux-video',
|
type MediaType,
|
||||||
VIDEO_INTERN: 'video-intern',
|
type MediaItem,
|
||||||
YOUTUBE: 'youtube',
|
type ImageMediaItem,
|
||||||
TIKTOK: 'tiktok',
|
type VideoMediaItem,
|
||||||
PAGE: 'page-intern',
|
type YouTubeMediaItem,
|
||||||
PAGE_EXTERNAL: 'page-external',
|
type TikTokMediaItem,
|
||||||
} as const;
|
type PageMediaItem,
|
||||||
|
};
|
||||||
|
|
||||||
export type MediaType = typeof MEDIA_TYPES[keyof typeof MEDIA_TYPES] | null;
|
// ============================================================================
|
||||||
|
// UTILITY FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
// Normalize type - treat NULL as SUPABASE_IMAGE (backward compatibility)
|
// Normalize type - treat NULL as SUPABASE_IMAGE (backward compatibility)
|
||||||
export function normalizeMediaType(type: string | null | undefined): MediaType {
|
export function normalizeMediaType(type: string | null | undefined): MediaType {
|
||||||
@ -53,6 +65,42 @@ export function getThumbnailField(type: MediaType): 'thumbnail_url' | null {
|
|||||||
return 'thumbnail_url';
|
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
|
// Media renderer registry
|
||||||
export interface MediaRendererProps {
|
export interface MediaRendererProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -7,12 +7,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Import your components
|
// Import your components
|
||||||
import LogViewerWidget from '@/components/widgets/LogViewerWidget';
|
|
||||||
import PhotoGrid from '@/components/PhotoGrid';
|
import PhotoGrid from '@/components/PhotoGrid';
|
||||||
import PhotoGridWidget from '@/components/widgets/PhotoGridWidget';
|
import PhotoGridWidget from '@/components/widgets/PhotoGridWidget';
|
||||||
import PhotoCardWidget from '@/components/widgets/PhotoCardWidget';
|
import PhotoCardWidget from '@/components/widgets/PhotoCardWidget';
|
||||||
|
import PageCardWidget from '@/components/widgets/PageCardWidget';
|
||||||
import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget';
|
import LayoutContainerWidget from '@/components/widgets/LayoutContainerWidget';
|
||||||
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
|
import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
|
||||||
|
import GalleryWidget from '@/components/widgets/GalleryWidget';
|
||||||
|
|
||||||
export function registerAllWidgets() {
|
export function registerAllWidgets() {
|
||||||
// Clear existing registrations (useful for HMR)
|
// Clear existing registrations (useful for HMR)
|
||||||
@ -28,6 +29,8 @@ export function registerAllWidgets() {
|
|||||||
description: 'Display photos in a responsive grid layout',
|
description: 'Display photos in a responsive grid layout',
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
defaultProps: {},
|
defaultProps: {},
|
||||||
|
// Note: PhotoGrid fetches data internally based on navigation context
|
||||||
|
// For configurable picture selection, use 'photo-grid-widget' instead
|
||||||
minSize: { width: 300, height: 200 },
|
minSize: { width: 300, height: 200 },
|
||||||
resizable: true,
|
resizable: true,
|
||||||
tags: ['photo', 'grid', 'gallery']
|
tags: ['photo', 'grid', 'gallery']
|
||||||
@ -43,7 +46,10 @@ export function registerAllWidgets() {
|
|||||||
description: 'Display a single photo card with details',
|
description: 'Display a single photo card with details',
|
||||||
icon: Monitor,
|
icon: Monitor,
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
pictureId: null
|
pictureId: null,
|
||||||
|
showHeader: true,
|
||||||
|
showFooter: true,
|
||||||
|
contentDisplay: 'below'
|
||||||
},
|
},
|
||||||
configSchema: {
|
configSchema: {
|
||||||
pictureId: {
|
pictureId: {
|
||||||
@ -51,6 +57,29 @@ export function registerAllWidgets() {
|
|||||||
label: 'Select Picture',
|
label: 'Select Picture',
|
||||||
description: 'Choose a picture from your published images',
|
description: 'Choose a picture from your published images',
|
||||||
default: null
|
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 },
|
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
|
// Content widgets
|
||||||
widgetRegistry.register({
|
widgetRegistry.register({
|
||||||
component: MarkdownTextWidget,
|
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 { AdminSidebar, AdminActiveSection } from "@/components/admin/AdminSidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
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 AdminPage = () => {
|
||||||
const { user, session, loading, roles } = useAuth();
|
const { user, session, loading, roles } = useAuth();
|
||||||
@ -36,6 +38,8 @@ const AdminPage = () => {
|
|||||||
{activeSection === 'users' && <UserManagerSection />}
|
{activeSection === 'users' && <UserManagerSection />}
|
||||||
{activeSection === 'dashboard' && <DashboardSection />}
|
{activeSection === 'dashboard' && <DashboardSection />}
|
||||||
{activeSection === 'server' && <ServerSection session={session} />}
|
{activeSection === 'server' && <ServerSection session={session} />}
|
||||||
|
{activeSection === 'bans' && <BansSection session={session} />}
|
||||||
|
{activeSection === 'violations' && <ViolationsSection session={session} />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -57,6 +61,14 @@ const DashboardSection = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const BansSection = ({ session }: { session: any }) => (
|
||||||
|
<BansManager session={session} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ViolationsSection = ({ session }: { session: any }) => (
|
||||||
|
<ViolationsMonitor session={session} />
|
||||||
|
);
|
||||||
|
|
||||||
const ServerSection = ({ session }: { session: any }) => {
|
const ServerSection = ({ session }: { session: any }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
@ -115,6 +156,27 @@ const ServerSection = ({ session }: { session: any }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import MobileFeed from "@/components/feed/MobileFeed";
|
|||||||
import { useMediaRefresh } from "@/contexts/MediaRefreshContext";
|
import { useMediaRefresh } from "@/contexts/MediaRefreshContext";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { useState, useEffect } from "react";
|
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 { ListLayout } from "@/components/ListLayout";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import type { FeedSortOption } from "@/hooks/useFeedData";
|
import type { FeedSortOption } from "@/hooks/useFeedData";
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
|
const { slug } = useParams<{ slug?: string }>();
|
||||||
|
const categorySlugs = slug ? [slug] : undefined;
|
||||||
|
|
||||||
const { refreshKey } = useMediaRefresh();
|
const { refreshKey } = useMediaRefresh();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@ -22,27 +25,42 @@ const Index = () => {
|
|||||||
}, [viewMode]);
|
}, [viewMode]);
|
||||||
const [sortBy, setSortBy] = useState<FeedSortOption>('latest');
|
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 (
|
return (
|
||||||
<div className="bg-background">
|
<div className="bg-background">
|
||||||
<div className="md:py-2">
|
<div className="md:py-2">
|
||||||
<div className="text-center">
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
{/* Mobile Feed View */}
|
{/* Mobile Feed View */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<div className="flex justify-between items-center px-4 mb-4 pt-2">
|
<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)}>
|
<div className="flex items-center gap-3">
|
||||||
<ToggleGroupItem value="latest" aria-label="Latest Posts" size="sm">
|
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
|
||||||
<Clock className="h-4 w-4 mr-2" />
|
<ToggleGroupItem value="latest" aria-label="Latest Posts" size="sm">
|
||||||
Latest
|
<Clock className="h-4 w-4 mr-2" />
|
||||||
</ToggleGroupItem>
|
Latest
|
||||||
<ToggleGroupItem value="top" aria-label="Top Posts" size="sm">
|
</ToggleGroupItem>
|
||||||
<TrendingUp className="h-4 w-4 mr-2" />
|
<ToggleGroupItem value="top" aria-label="Top Posts" size="sm">
|
||||||
Top
|
<TrendingUp className="h-4 w-4 mr-2" />
|
||||||
</ToggleGroupItem>
|
Top
|
||||||
</ToggleGroup>
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
{renderCategoryBreadcrumb()}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToggleGroup type="single" value={viewMode === 'list' ? 'list' : 'grid'} onValueChange={(v) => v && setViewMode(v as any)}>
|
<ToggleGroup type="single" value={viewMode === 'list' ? 'list' : 'grid'} onValueChange={(v) => v && setViewMode(v as any)}>
|
||||||
<ToggleGroupItem value="grid" aria-label="Card View" size="sm">
|
<ToggleGroupItem value="grid" aria-label="Card View" size="sm">
|
||||||
@ -54,25 +72,28 @@ const Index = () => {
|
|||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
{viewMode === 'list' ? (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Desktop/Tablet Grid View */
|
/* Desktop/Tablet Grid View */
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<div className="flex justify-between px-4 mb-4">
|
<div className="flex justify-between px-4 mb-4">
|
||||||
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
|
<div className="flex items-center gap-3">
|
||||||
<ToggleGroupItem value="latest" aria-label="Latest Posts">
|
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
|
||||||
<Clock className="h-4 w-4 mr-2" />
|
<ToggleGroupItem value="latest" aria-label="Latest Posts">
|
||||||
Latest
|
<Clock className="h-4 w-4 mr-2" />
|
||||||
</ToggleGroupItem>
|
Latest
|
||||||
<ToggleGroupItem value="top" aria-label="Top Posts">
|
</ToggleGroupItem>
|
||||||
<TrendingUp className="h-4 w-4 mr-2" />
|
<ToggleGroupItem value="top" aria-label="Top Posts">
|
||||||
Top
|
<TrendingUp className="h-4 w-4 mr-2" />
|
||||||
</ToggleGroupItem>
|
Top
|
||||||
</ToggleGroup>
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
{renderCategoryBreadcrumb()}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as any)}>
|
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as any)}>
|
||||||
<ToggleGroupItem value="grid" aria-label="Grid View">
|
<ToggleGroupItem value="grid" aria-label="Grid View">
|
||||||
@ -88,11 +109,11 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'grid' ? (
|
{viewMode === 'grid' ? (
|
||||||
<PhotoGrid key={refreshKey} sortBy={sortBy} showVideos={true} />
|
<PhotoGrid key={refreshKey} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} />
|
||||||
) : viewMode === 'large' ? (
|
) : 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -31,6 +31,9 @@ const PlaygroundCanvas = () => {
|
|||||||
handleDumpJson,
|
handleDumpJson,
|
||||||
handleLoadTemplate,
|
handleLoadTemplate,
|
||||||
handleSaveTemplate,
|
handleSaveTemplate,
|
||||||
|
handleDeleteTemplate,
|
||||||
|
handleToggleVisibility,
|
||||||
|
handleRenameLayout,
|
||||||
handlePasteJson,
|
handlePasteJson,
|
||||||
handleLoadContext,
|
handleLoadContext,
|
||||||
handleExportHtml,
|
handleExportHtml,
|
||||||
@ -141,6 +144,9 @@ const PlaygroundCanvas = () => {
|
|||||||
onSendTestEmail={handleSendTestEmail}
|
onSendTestEmail={handleSendTestEmail}
|
||||||
templates={templates}
|
templates={templates}
|
||||||
handleLoadTemplate={handleLoadTemplate}
|
handleLoadTemplate={handleLoadTemplate}
|
||||||
|
handleDeleteTemplate={handleDeleteTemplate}
|
||||||
|
handleToggleVisibility={handleToggleVisibility}
|
||||||
|
handleRenameLayout={handleRenameLayout}
|
||||||
onSaveTemplateClick={() => setIsSaveDialogOpen(true)}
|
onSaveTemplateClick={() => setIsSaveDialogOpen(true)}
|
||||||
onPasteJsonClick={() => setIsPasteDialogOpen(true)}
|
onPasteJsonClick={() => setIsPasteDialogOpen(true)}
|
||||||
handleDumpJson={handleDumpJson}
|
handleDumpJson={handleDumpJson}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, Suspense, lazy } from "react";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { usePostNavigation } from "@/hooks/usePostNavigation";
|
import { usePostNavigation } from "@/hooks/usePostNavigation";
|
||||||
@ -18,6 +18,8 @@ import { CompactRenderer } from "./Post/renderers/CompactRenderer";
|
|||||||
import { usePostActions } from "./Post/usePostActions";
|
import { usePostActions } from "./Post/usePostActions";
|
||||||
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
|
import { exportMarkdown, downloadMediaItem } from "./Post/PostActions";
|
||||||
import { DeleteDialog } from "./Post/components/DeleteDialogs";
|
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/theme.css';
|
||||||
import '@vidstack/react/player/styles/default/layouts/video.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 navigate = useNavigate();
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { navigationData, setNavigationData, preloadImage } = usePostNavigation();
|
const { navigationData, setNavigationData } = usePostNavigation();
|
||||||
const { setWizardImage } = useWizardContext();
|
const { setWizardImage } = useWizardContext();
|
||||||
|
|
||||||
// ... state ...
|
// ... state ...
|
||||||
@ -347,6 +349,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const moveItem = (index: number, direction: 'up' | 'down') => {
|
const moveItem = (index: number, direction: 'up' | 'down') => {
|
||||||
const newItems = [...localMediaItems];
|
const newItems = [...localMediaItems];
|
||||||
if (direction === 'up' && index > 0) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@ -741,15 +735,6 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
|
|||||||
toast.success(translate("TikTok video added"));
|
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 () => {
|
const handleLike = async () => {
|
||||||
if (!user || !mediaItem) {
|
if (!user || !mediaItem) {
|
||||||
@ -920,6 +905,8 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
|
|||||||
onMediaSelect: setMediaItem,
|
onMediaSelect: setMediaItem,
|
||||||
onExpand: (item: MediaItem) => { setMediaItem(item); setShowLightbox(true); },
|
onExpand: (item: MediaItem) => { setMediaItem(item); setShowLightbox(true); },
|
||||||
onDownload: handleDownload,
|
onDownload: handleDownload,
|
||||||
|
onCategoryManagerOpen: () => actions.setShowCategoryManager(true),
|
||||||
|
|
||||||
|
|
||||||
currentImageIndex,
|
currentImageIndex,
|
||||||
videoPlaybackUrl,
|
videoPlaybackUrl,
|
||||||
@ -1110,6 +1097,16 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CategoryManager
|
||||||
|
isOpen={actions.showCategoryManager}
|
||||||
|
onClose={() => actions.setShowCategoryManager(false)}
|
||||||
|
currentPageId={post?.id}
|
||||||
|
currentPageMeta={post?.meta}
|
||||||
|
onPageMetaUpdate={actions.handleMetaUpdate}
|
||||||
|
filterByType="pages"
|
||||||
|
defaultMetaType="pages"
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</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 { useNavigate } from "react-router-dom";
|
||||||
import { useOrganization } from "@/contexts/OrganizationContext";
|
import { useOrganization } from "@/contexts/OrganizationContext";
|
||||||
import { PostRendererProps } from '../types';
|
import { PostRendererProps } from '../types';
|
||||||
@ -6,11 +6,10 @@ import { useMediaQuery } from "@/hooks/use-media-query";
|
|||||||
import { isVideoType, normalizeMediaType, detectMediaType } from "@/lib/mediaRegistry";
|
import { isVideoType, normalizeMediaType, detectMediaType } from "@/lib/mediaRegistry";
|
||||||
|
|
||||||
// Extracted Components
|
// Extracted Components
|
||||||
import { CompactFilmStrip } from "./components/CompactFilmStrip";
|
|
||||||
import { MobileGroupedFeed } from "./components/MobileGroupedFeed";
|
import { MobileGroupedFeed } from "./components/MobileGroupedFeed";
|
||||||
import { CompactPostHeader } from "./components/CompactPostHeader";
|
import { CompactPostHeader } from "./components/CompactPostHeader";
|
||||||
import { CompactMediaViewer } from "./components/CompactMediaViewer";
|
|
||||||
import { CompactMediaDetails } from "./components/CompactMediaDetails";
|
import { CompactMediaDetails } from "./components/CompactMediaDetails";
|
||||||
|
import { Gallery } from "./components/Gallery";
|
||||||
|
|
||||||
// Lazy load ImageEditor
|
// Lazy load ImageEditor
|
||||||
const ImageEditor = React.lazy(() => import("@/components/ImageEditor").then(module => ({ default: module.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,
|
isOwner, isLiked, likesCount,
|
||||||
onEditPost, onViewModeChange, onExportMarkdown,
|
onEditPost, onViewModeChange, onExportMarkdown,
|
||||||
onDeletePost, onDeletePicture, onLike, onEditPicture,
|
onDeletePost, onDeletePicture, onLike, onEditPicture,
|
||||||
onMediaSelect, onExpand, onDownload,
|
onMediaSelect, onExpand, onDownload, onCategoryManagerOpen,
|
||||||
currentImageIndex, videoPlaybackUrl, videoPosterUrl,
|
currentImageIndex, videoPlaybackUrl, videoPosterUrl,
|
||||||
versionImages, handlePrevImage, handleNavigate, navigationData,
|
versionImages, handlePrevImage, handleNavigate, navigationData,
|
||||||
isEditMode, localPost, setLocalPost, localMediaItems, setLocalMediaItems, onMoveItem,
|
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 effectiveType = mediaItem.type || detectMediaType(mediaItem.image_url);
|
||||||
const isVideo = isVideoType(normalizeMediaType(effectiveType));
|
const isVideo = isVideoType(normalizeMediaType(effectiveType));
|
||||||
|
|
||||||
|
console.log('mediaItem', mediaItem);
|
||||||
|
console.log('isVideo', isVideo);
|
||||||
|
console.log('effectiveType', effectiveType);
|
||||||
|
console.log('mediaItems', mediaItems);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className || "h-full"}>
|
<div className={props.className || "h-full"}>
|
||||||
{/* Mobile Header - Controls and Info at Top */}
|
{/* Mobile Header - Controls and Info at Top */}
|
||||||
@ -61,6 +66,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
|
|||||||
onEditPost={onEditPost!}
|
onEditPost={onEditPost!}
|
||||||
onDeletePicture={onDeletePicture!}
|
onDeletePicture={onDeletePicture!}
|
||||||
onDeletePost={onDeletePost!}
|
onDeletePost={onDeletePost!}
|
||||||
|
onCategoryManagerOpen={onCategoryManagerOpen}
|
||||||
mediaItems={mediaItems}
|
mediaItems={mediaItems}
|
||||||
localMediaItems={localMediaItems}
|
localMediaItems={localMediaItems}
|
||||||
/>
|
/>
|
||||||
@ -72,43 +78,29 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
|
|||||||
{/* Left Column - Media */}
|
{/* Left Column - Media */}
|
||||||
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative lg:h-full`}>
|
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative lg:h-full`}>
|
||||||
|
|
||||||
{/* Row 1: Top Spacer */}
|
{/* Desktop Gallery - Combines Media Viewer + Filmstrip */}
|
||||||
<div className="hidden lg:block h-[50px]"></div>
|
<div className="hidden lg:block h-full">
|
||||||
|
<Gallery
|
||||||
{/* 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}
|
|
||||||
mediaItems={mediaItems}
|
mediaItems={mediaItems}
|
||||||
currentImageIndex={currentImageIndex}
|
selectedItem={mediaItem}
|
||||||
navigationData={navigationData}
|
|
||||||
handleNavigate={handleNavigate!}
|
|
||||||
onMediaSelect={onMediaSelect}
|
onMediaSelect={onMediaSelect}
|
||||||
onExpand={onExpand}
|
onExpand={onExpand}
|
||||||
cacheBustKeys={cacheBustKeys}
|
|
||||||
navigate={navigate}
|
|
||||||
isOwner={!!isOwner}
|
isOwner={!!isOwner}
|
||||||
|
isEditMode={!!isEditMode}
|
||||||
|
localMediaItems={localMediaItems}
|
||||||
|
setLocalMediaItems={setLocalMediaItems}
|
||||||
|
onDeletePicture={onDeletePicture!}
|
||||||
|
onGalleryPickerOpen={onGalleryPickerOpen!}
|
||||||
|
cacheBustKeys={cacheBustKeys}
|
||||||
|
navigationData={navigationData}
|
||||||
|
handleNavigate={handleNavigate!}
|
||||||
|
navigate={navigate}
|
||||||
videoPlaybackUrl={videoPlaybackUrl}
|
videoPlaybackUrl={videoPlaybackUrl}
|
||||||
videoPosterUrl={videoPosterUrl}
|
videoPosterUrl={videoPosterUrl}
|
||||||
|
showDesktopLayout={!!showDesktopLayout}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Mobile View - Grouped Feed - Hidden on Desktop */}
|
||||||
<MobileGroupedFeed
|
<MobileGroupedFeed
|
||||||
mediaItems={mediaItems}
|
mediaItems={mediaItems}
|
||||||
@ -150,6 +142,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
|
|||||||
onEditPost={onEditPost!}
|
onEditPost={onEditPost!}
|
||||||
onDeletePicture={onDeletePicture!}
|
onDeletePicture={onDeletePicture!}
|
||||||
onDeletePost={onDeletePost!}
|
onDeletePost={onDeletePost!}
|
||||||
|
onCategoryManagerOpen={onCategoryManagerOpen}
|
||||||
mediaItems={mediaItems}
|
mediaItems={mediaItems}
|
||||||
localMediaItems={localMediaItems}
|
localMediaItems={localMediaItems}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface CompactFilmStripProps {
|
|||||||
onDeletePicture: () => void;
|
onDeletePicture: () => void;
|
||||||
onGalleryPickerOpen: (index: number) => void;
|
onGalleryPickerOpen: (index: number) => void;
|
||||||
cacheBustKeys: Record<string, number>;
|
cacheBustKeys: Record<string, number>;
|
||||||
|
thumbnailLayout?: 'strip' | 'grid';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
|
export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
|
||||||
@ -29,25 +30,32 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
|
|||||||
isOwner,
|
isOwner,
|
||||||
onDeletePicture,
|
onDeletePicture,
|
||||||
onGalleryPickerOpen,
|
onGalleryPickerOpen,
|
||||||
cacheBustKeys
|
cacheBustKeys,
|
||||||
|
thumbnailLayout = 'strip'
|
||||||
}) => {
|
}) => {
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(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(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (container) {
|
if (container) {
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
if (e.deltaY !== 0) {
|
if (e.deltaY !== 0) {
|
||||||
e.preventDefault();
|
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 });
|
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
return () => container.removeEventListener('wheel', handleWheel);
|
return () => container.removeEventListener('wheel', handleWheel);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [thumbnailLayout]);
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -133,11 +141,14 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="p-0 mt-2 landscape:p-0 lg:landscape:p-0 hidden lg:block landscape:block w-auto max-w-full">
|
||||||
<div className="flex items-center justify-between mb-1 landscape:hidden lg:landscape:flex">
|
<div
|
||||||
<span className="text-foreground text-xs font-medium"><T>Gallery</T> ({groupedItems.length})</span>
|
className={thumbnailLayout === 'grid'
|
||||||
</div>
|
? "flex flex-wrap gap-1 justify-center max-h-[200px] overflow-y-auto scrollbar-hide"
|
||||||
<div className="flex gap-3 overflow-x-auto scrollbar-hide landscape:h-16 lg:landscape:h-36" ref={scrollContainerRef}>
|
: "flex gap-1 overflow-x-auto scrollbar-hide justify-center"
|
||||||
|
}
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
>
|
||||||
{groupedItems.map((item, index) => (
|
{groupedItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.renderKey || item.id}
|
key={item.renderKey || item.id}
|
||||||
@ -145,7 +156,7 @@ export const CompactFilmStrip: React.FC<CompactFilmStripProps> = ({
|
|||||||
onDragStart={(e) => isEditMode && handleDragStart(e, index)}
|
onDragStart={(e) => isEditMode && handleDragStart(e, index)}
|
||||||
onDragOver={(e) => isEditMode && handleDragOver(e, index)}
|
onDragOver={(e) => isEditMode && handleDragOver(e, index)}
|
||||||
onDrop={(e) => isEditMode && handleDrop(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'
|
? 'shadow-lg scale-105'
|
||||||
: 'hover:scale-102'
|
: 'hover:scale-102'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ interface CompactMediaViewerProps {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
videoPlaybackUrl?: string;
|
videoPlaybackUrl?: string;
|
||||||
videoPosterUrl?: string;
|
videoPosterUrl?: string;
|
||||||
|
imageFit?: 'contain' | 'cover';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
|
export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
|
||||||
@ -40,7 +41,8 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
|
|||||||
navigate,
|
navigate,
|
||||||
isOwner,
|
isOwner,
|
||||||
videoPlaybackUrl,
|
videoPlaybackUrl,
|
||||||
videoPosterUrl
|
videoPosterUrl,
|
||||||
|
imageFit = 'cover'
|
||||||
}) => {
|
}) => {
|
||||||
const playerRef = useRef<MediaPlayerInstance>(null);
|
const playerRef = useRef<MediaPlayerInstance>(null);
|
||||||
const [externalVideoState, setExternalVideoState] = React.useState<Record<string, boolean>>({});
|
const [externalVideoState, setExternalVideoState] = React.useState<Record<string, boolean>>({});
|
||||||
@ -103,26 +105,12 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
|
|||||||
}, [showDesktopLayout, currentVersions, currentVersionIndex, mediaItem]);
|
}, [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 ? (
|
{showDesktopLayout ? (
|
||||||
isVideo ? (
|
isVideo ? (
|
||||||
mediaItem.type === 'tiktok' ? (
|
mediaItem.mediaType === 'tiktok' ? (
|
||||||
<div className="w-full h-full bg-black flex justify-center">
|
<div className="w-full h-full bg-black flex justify-center">
|
||||||
<iframe
|
<iframe
|
||||||
src={mediaItem.image_url}
|
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 url = (mediaItem.meta as any)?.url || mediaItem.image_url;
|
||||||
const ytId = getYouTubeVideoId(url);
|
const ytId = getYouTubeVideoId(url);
|
||||||
|
|
||||||
@ -200,7 +188,7 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
|
|||||||
sizes="(max-width: 1024px) 100vw, 1200px"
|
sizes="(max-width: 1024px) 100vw, 1200px"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<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" />
|
<Play className="w-12 h-12 text-white fill-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -209,11 +197,13 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveImage
|
<ResponsiveImage
|
||||||
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
|
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
|
||||||
alt={mediaItem.title}
|
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)}
|
onClick={() => onExpand(mediaItem)}
|
||||||
title="Double-tap to view fullscreen"
|
title="Double-tap to view fullscreen"
|
||||||
sizes="(max-width: 1024px) 100vw, 1200px"
|
sizes="(max-width: 1024px) 100vw, 1200px"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -27,6 +27,7 @@ interface CompactPostHeaderProps {
|
|||||||
onEditPost: () => void;
|
onEditPost: () => void;
|
||||||
onDeletePicture: () => void;
|
onDeletePicture: () => void;
|
||||||
onDeletePost: () => void;
|
onDeletePost: () => void;
|
||||||
|
onCategoryManagerOpen?: () => void;
|
||||||
mediaItems: PostMediaItem[];
|
mediaItems: PostMediaItem[];
|
||||||
localMediaItems?: PostMediaItem[];
|
localMediaItems?: PostMediaItem[];
|
||||||
}
|
}
|
||||||
@ -46,6 +47,7 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
|
|||||||
onEditPost,
|
onEditPost,
|
||||||
onDeletePicture,
|
onDeletePicture,
|
||||||
onDeletePost,
|
onDeletePost,
|
||||||
|
onCategoryManagerOpen,
|
||||||
mediaItems,
|
mediaItems,
|
||||||
localMediaItems
|
localMediaItems
|
||||||
}) => {
|
}) => {
|
||||||
@ -72,6 +74,30 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
|
|||||||
<div className="p-3 border-b">
|
<div className="p-3 border-b">
|
||||||
{post.title && post.title !== mediaItem?.title && (<h1 className="text-lg font-bold mb-1">{post.title}</h1>)}
|
{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" />}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@ -151,6 +177,7 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={onEditPost}><Edit3 className="mr-2 h-4 w-4" /><span>Edit Post Wizard</span></DropdownMenuItem>
|
<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={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>
|
<DropdownMenuItem onClick={onDeletePost} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /><span>Delete whole post</span></DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</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 { User } from '@supabase/supabase-js';
|
||||||
import { ImageFile, MediaType, MediaItem as GlobalMediaItem } from "@/types";
|
import { ImageFile, MediaItem } from "@/types";
|
||||||
|
|
||||||
// Local Media Item definition extensions
|
// PostMediaItem extends MediaItem with post-specific fields
|
||||||
// We extend GlobalMediaItem to ensure compatibility with shared components/utils
|
export type PostMediaItem = MediaItem & {
|
||||||
export interface PostMediaItem extends GlobalMediaItem {
|
|
||||||
post_id: string | null;
|
post_id: string | null;
|
||||||
position: number;
|
|
||||||
renderKey?: string;
|
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 {
|
export interface PostItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -21,10 +15,25 @@ export interface PostItem {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
pictures?: PostMediaItem[];
|
pictures?: PostMediaItem[];
|
||||||
settings?: any;
|
settings?: PostSettings;
|
||||||
isPseudo?: boolean;
|
meta?: PostMeta;
|
||||||
type?: string;
|
isPseudo?: boolean; // For single pictures without posts
|
||||||
meta?: any;
|
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 {
|
export interface UserProfile {
|
||||||
@ -76,6 +85,8 @@ export interface PostRendererProps {
|
|||||||
onMediaSelect: (item: PostMediaItem) => void;
|
onMediaSelect: (item: PostMediaItem) => void;
|
||||||
onExpand: (item: PostMediaItem) => void;
|
onExpand: (item: PostMediaItem) => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
|
onCategoryManagerOpen?: () => void;
|
||||||
|
|
||||||
|
|
||||||
// Comparison helpers
|
// Comparison helpers
|
||||||
currentImageIndex: number;
|
currentImageIndex: number;
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export const usePostActions = ({
|
|||||||
const [showTikTokDialog, setShowTikTokDialog] = useState(false);
|
const [showTikTokDialog, setShowTikTokDialog] = useState(false);
|
||||||
const [showGalleryPicker, setShowGalleryPicker] = useState(false);
|
const [showGalleryPicker, setShowGalleryPicker] = useState(false);
|
||||||
const [showAIWizard, setShowAIWizard] = useState(false);
|
const [showAIWizard, setShowAIWizard] = useState(false);
|
||||||
|
const [showCategoryManager, setShowCategoryManager] = useState(false);
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
@ -152,6 +153,23 @@ export const usePostActions = ({
|
|||||||
return;
|
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 {
|
return {
|
||||||
// States
|
// States
|
||||||
@ -161,11 +179,13 @@ export const usePostActions = ({
|
|||||||
showTikTokDialog, setShowTikTokDialog,
|
showTikTokDialog, setShowTikTokDialog,
|
||||||
showGalleryPicker, setShowGalleryPicker,
|
showGalleryPicker, setShowGalleryPicker,
|
||||||
showAIWizard, setShowAIWizard,
|
showAIWizard, setShowAIWizard,
|
||||||
|
showCategoryManager, setShowCategoryManager,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
handleDeletePost,
|
handleDeletePost,
|
||||||
handleDeletePicture,
|
handleDeletePicture,
|
||||||
handleUnlinkImage,
|
handleUnlinkImage,
|
||||||
|
handleMetaUpdate,
|
||||||
// handleLike // Kept in Post.tsx for now due to state coupling
|
// 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}
|
isEditMode={isEditMode}
|
||||||
onToggleEditMode={() => setIsEditMode(!isEditMode)}
|
onToggleEditMode={() => setIsEditMode(!isEditMode)}
|
||||||
onPageUpdate={handlePageUpdate}
|
onPageUpdate={handlePageUpdate}
|
||||||
|
onMetaUpdated={() => userId && page.slug && fetchUserPageData(userId, page.slug)}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -99,21 +99,13 @@ const UserProfile = () => {
|
|||||||
}, [feedPosts]);
|
}, [feedPosts]);
|
||||||
|
|
||||||
const fetchUserProfile = async () => {
|
const fetchUserProfile = async () => {
|
||||||
console.log('fetchUserProfile', userId);
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch profile with user_roles
|
||||||
const { data: profile, error: profileError } = await supabase
|
const { data: profile, error: profileError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
user_roles (role),
|
user_roles (role)
|
||||||
user_organizations (
|
|
||||||
role,
|
|
||||||
organizations (
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
slug
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`)
|
`)
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@ -140,22 +132,32 @@ const UserProfile = () => {
|
|||||||
|
|
||||||
setUserProfile(prev => JSON.stringify(prev) !== JSON.stringify(newProfile) ? newProfile : prev);
|
setUserProfile(prev => JSON.stringify(prev) !== JSON.stringify(newProfile) ? newProfile : prev);
|
||||||
|
|
||||||
// Process Orgs and Roles from the profile fetch
|
// Fetch user organizations separately (no direct FK from profiles to user_organizations)
|
||||||
if (profile) {
|
if (userId) {
|
||||||
// Process Organizations
|
const { data: userOrgs, error: orgsError } = await supabase
|
||||||
const orgsData = (profile as any).user_organizations || [];
|
.from('user_organizations')
|
||||||
const orgs = orgsData.map((item: any) => ({
|
.select(`
|
||||||
id: item.organizations?.id,
|
role,
|
||||||
name: item.organizations?.name,
|
organizations (
|
||||||
slug: item.organizations?.slug,
|
id,
|
||||||
role: item.role
|
name,
|
||||||
})).filter((o: any) => o.id); // Filter out invalid
|
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)
|
setOrganizations(orgs);
|
||||||
// const roles = (profile as any).user_roles?.map((r: any) => r.role) || [];
|
}
|
||||||
// setRoles(roles); // If we had a roles state
|
|
||||||
} else {
|
} else {
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,18 +7,137 @@ export interface ImageFile {
|
|||||||
description?: string;
|
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)
|
// Media type identifiers
|
||||||
export interface MediaItem {
|
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;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
image_url: string; // For images: image URL, for videos: HLS stream URL
|
image_url: string; // For images: image URL, for videos: HLS stream URL
|
||||||
thumbnail_url: string | null;
|
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
|
meta: any | null; // Video metadata for mux-video, null for images
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user