v1 cleanup

This commit is contained in:
lovebird 2026-03-26 23:01:41 +01:00
parent d2843f6ee4
commit 46e25ccd48
232 changed files with 38422 additions and 42977 deletions

View File

@ -16,16 +16,17 @@ import { WebSocketProvider } from "@/contexts/WS_Socket";
import { registerAllWidgets } from "@/lib/registerWidgets";
import TopNavigation from "@/components/TopNavigation";
import Footer from "@/components/Footer";
const GlobalDragDrop = React.lazy(() => import("@/components/GlobalDragDrop"));
import { DragDropProvider } from "@/contexts/DragDropContext";
import { useAppStore } from "@/store/appStore";
const GlobalDragDrop = React.lazy(() => import("@/components/GlobalDragDrop"));
// Register all widgets on app boot
registerAllWidgets();
import Index from "./pages/Index";
import Auth from "./pages/Auth";
const UpdatePassword = React.lazy(() => import("./pages/UpdatePassword"));
import Profile from "./pages/Profile";
@ -34,8 +35,8 @@ const Post = React.lazy(() => import("./modules/posts/PostPage"));
import UserProfile from "./pages/UserProfile";
import TagPage from "./pages/TagPage";
import SearchResults from "./pages/SearchResults";
const LogsPage = React.lazy(() => import("./components/logging/LogsPage"));
const LogsPage = React.lazy(() => import("./components/logging/LogsPage"));
const Wizard = React.lazy(() => import("./pages/Wizard"));
const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings"));
const NotFound = React.lazy(() => import("./pages/NotFound"));
@ -51,7 +52,6 @@ let VideoPlayerPlaygroundIntern: any;
let PlaygroundImages: any;
let PlaygroundImageEditor: any;
let VideoGenPlayground: any;
let GridSearchPlayground: any;
let PlaygroundCanvas: any;
let TypesPlayground: any;
let VariablePlayground: any;
@ -76,13 +76,11 @@ if (enablePlaygrounds) {
PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages"));
PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor"));
VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground"));
GridSearchPlayground = React.lazy(() => import("./modules/places/GridSearchPlayground"));
PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCanvas"));
TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground"));
VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor })));
I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat"));
PlacesModule = React.lazy(() => import("./modules/places/index"));
LocationDetail = React.lazy(() => import("./modules/places/LocationDetail"));
Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser"));
@ -101,7 +99,6 @@ const EditPost = React.lazy(() => import("./modules/posts/EditPost"));
// <GlobalDebug />
import { EcommerceBundleWrapper } from "./bundles/ecommerce";
// Create a single tracker instance outside the component to avoid re-creation on re-renders
/*
const tracker = new Tracker({
@ -114,25 +111,6 @@ tracker.use(trackerAssist());
const AppWrapper = () => {
const location = useLocation();
// Start tracker once on mount
/*
React.useEffect(() => {
tracker.start().catch(() => {
// Silently ignore — DoNotTrack is active or browser doesn't support required APIs
console.log('OpenReplay tracker failed to start');
});
}, []);
// Update user identity when auth state changes
React.useEffect(() => {
if (user?.email) {
tracker.setUserID(user.email);
tracker.setMetadata('roles', roles.join(','));
}
}, [user?.email, roles]);
*/
const searchParams = new URLSearchParams(location.search);
const isPageEditor = location.pathname.includes('/pages/') && searchParams.get('edit') === 'true';
const isFullScreenPage = location.pathname.startsWith('/video-feed') || isPageEditor;
@ -206,7 +184,6 @@ const AppWrapper = () => {
{/* Playground Routes */}
{enablePlaygrounds && (
<>
<Route path="/playground/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearchPlayground /></React.Suspense>} />
<Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} />
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />

File diff suppressed because it is too large Load Diff

View File

@ -1,350 +1,350 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, Bookmark } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Collection {
id: string;
name: string;
description: string;
slug: string;
is_public: boolean;
created_at: string;
}
interface AddToCollectionModalProps {
isOpen: boolean;
onClose: () => void;
pictureId?: string;
postId?: string;
}
const AddToCollectionModal = ({ isOpen, onClose, pictureId, postId }: AddToCollectionModalProps) => {
if (!pictureId && !postId) {
console.error('AddToCollectionModal requires either pictureId or postId');
return null;
}
const { user } = useAuth();
const { toast } = useToast();
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
const [showCreateForm, setShowCreateForm] = useState(false);
const [newCollection, setNewCollection] = useState({
name: '',
description: '',
is_public: true
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && user) {
fetchCollections();
fetchItemCollections();
}
}, [isOpen, user, pictureId, postId]);
const fetchCollections = async () => {
if (!user) return;
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching collections:', error);
return;
}
setCollections(data || []);
};
const fetchItemCollections = async () => {
if (!user) return;
if (postId) {
// Fetch for post
const { data, error } = await supabase
.from('collection_posts' as any) // Cast as any until types are generated
.select('collection_id')
.eq('post_id', postId);
if (error) {
console.error('Error fetching post collections:', error);
return;
}
const collectionIds = new Set(data.map((item: any) => item.collection_id));
setSelectedCollections(collectionIds);
} else if (pictureId) {
// Fetch for picture
const { data, error } = await supabase
.from('collection_pictures')
.select('collection_id')
.eq('picture_id', pictureId);
if (error) {
console.error('Error fetching picture collections:', error);
return;
}
const collectionIds = new Set(data.map(item => item.collection_id));
setSelectedCollections(collectionIds);
}
};
const createSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleCreateCollection = async () => {
if (!user || !newCollection.name.trim()) return;
setLoading(true);
const slug = createSlug(newCollection.name);
const { data, error } = await supabase
.from('collections')
.insert({
user_id: user.id,
name: newCollection.name.trim(),
description: newCollection.description.trim() || null,
slug,
is_public: newCollection.is_public
})
.select()
.single();
if (error) {
console.error('Error creating collection:', error);
toast({
title: "Error",
description: "Failed to create collection",
variant: "destructive"
});
setLoading(false);
return;
}
// Add item to new collection
if (postId) {
await supabase
.from('collection_posts' as any)
.insert({
collection_id: data.id,
post_id: postId
});
} else if (pictureId) {
await supabase
.from('collection_pictures')
.insert({
collection_id: data.id,
picture_id: pictureId
});
}
setCollections(prev => [data, ...prev]);
setSelectedCollections(prev => new Set([...prev, data.id]));
setNewCollection({ name: '', description: '', is_public: true });
setShowCreateForm(false);
setLoading(false);
toast({
title: "Success",
description: "Collection created and photo added!"
});
};
const handleToggleCollection = async (collectionId: string) => {
if (!user) return;
const isSelected = selectedCollections.has(collectionId);
if (isSelected) {
// Remove from collection
if (postId) {
const { error } = await supabase
.from('collection_posts' as any)
.delete()
.eq('collection_id', collectionId)
.eq('post_id', postId);
if (error) console.error('Error removing post from collection:', error);
} else if (pictureId) {
const { error } = await supabase
.from('collection_pictures')
.delete()
.eq('collection_id', collectionId)
.eq('picture_id', pictureId);
if (error) console.error('Error removing picture from collection:', error);
}
setSelectedCollections(prev => {
const newSet = new Set(prev);
newSet.delete(collectionId);
return newSet;
});
} else {
// Add to collection
if (postId) {
const { error } = await supabase
.from('collection_posts' as any)
.insert({
collection_id: collectionId,
post_id: postId
});
if (error) console.error('Error adding post to collection:', error);
} else if (pictureId) {
const { error } = await supabase
.from('collection_pictures')
.insert({
collection_id: collectionId,
picture_id: pictureId
});
if (error) console.error('Error adding picture to collection:', error);
}
setSelectedCollections(prev => new Set([...prev, collectionId]));
}
};
const handleSave = () => {
toast({
title: "Saved",
description: "Collection preferences updated!"
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Add to Collection
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Create new collection */}
{!showCreateForm ? (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowCreateForm(true)}
>
<Plus className="h-4 w-4 mr-2" />
Create New Collection
</Button>
) : (
<Card>
<CardContent className="p-4 space-y-3">
<Input
placeholder="Collection name"
value={newCollection.name}
onChange={(e) => setNewCollection(prev => ({ ...prev, name: e.target.value }))}
/>
<Textarea
placeholder="Description (optional)"
value={newCollection.description}
onChange={(e) => setNewCollection(prev => ({ ...prev, description: e.target.value }))}
rows={2}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="public"
checked={newCollection.is_public}
onCheckedChange={(checked) =>
setNewCollection(prev => ({ ...prev, is_public: checked as boolean }))
}
/>
<label htmlFor="public" className="text-sm">Make public</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreateCollection}
disabled={!newCollection.name.trim() || loading}
>
Create
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Existing collections */}
<div className="space-y-2 max-h-60 overflow-y-auto">
{collections.map((collection) => (
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
onClick={() => handleToggleCollection(collection.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{collection.name}</h4>
{collection.description && (
<p className="text-sm text-muted-foreground">
{collection.description}
</p>
)}
</div>
<Checkbox
checked={selectedCollections.has(collection.id)}
disabled
/>
</div>
</CardContent>
</Card>
))}
</div>
{collections.length === 0 && !showCreateForm && (
<p className="text-center text-muted-foreground py-4">
No collections yet. Create your first one!
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>
Done
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AddToCollectionModal;
import React, { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, Bookmark } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface Collection {
id: string;
name: string;
description: string;
slug: string;
is_public: boolean;
created_at: string;
}
interface AddToCollectionModalProps {
isOpen: boolean;
onClose: () => void;
pictureId?: string;
postId?: string;
}
const AddToCollectionModal = ({ isOpen, onClose, pictureId, postId }: AddToCollectionModalProps) => {
if (!pictureId && !postId) {
console.error('AddToCollectionModal requires either pictureId or postId');
return null;
}
const { user } = useAuth();
const { toast } = useToast();
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
const [showCreateForm, setShowCreateForm] = useState(false);
const [newCollection, setNewCollection] = useState({
name: '',
description: '',
is_public: true
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isOpen && user) {
fetchCollections();
fetchItemCollections();
}
}, [isOpen, user, pictureId, postId]);
const fetchCollections = async () => {
if (!user) return;
const { data, error } = await supabase
.from('collections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching collections:', error);
return;
}
setCollections(data || []);
};
const fetchItemCollections = async () => {
if (!user) return;
if (postId) {
// Fetch for post
const { data, error } = await supabase
.from('collection_posts' as any) // Cast as any until types are generated
.select('collection_id')
.eq('post_id', postId);
if (error) {
console.error('Error fetching post collections:', error);
return;
}
const collectionIds = new Set(data.map((item: any) => item.collection_id));
setSelectedCollections(collectionIds);
} else if (pictureId) {
// Fetch for picture
const { data, error } = await supabase
.from('collection_pictures')
.select('collection_id')
.eq('picture_id', pictureId);
if (error) {
console.error('Error fetching picture collections:', error);
return;
}
const collectionIds = new Set(data.map(item => item.collection_id));
setSelectedCollections(collectionIds);
}
};
const createSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleCreateCollection = async () => {
if (!user || !newCollection.name.trim()) return;
setLoading(true);
const slug = createSlug(newCollection.name);
const { data, error } = await supabase
.from('collections')
.insert({
user_id: user.id,
name: newCollection.name.trim(),
description: newCollection.description.trim() || null,
slug,
is_public: newCollection.is_public
})
.select()
.single();
if (error) {
console.error('Error creating collection:', error);
toast({
title: "Error",
description: "Failed to create collection",
variant: "destructive"
});
setLoading(false);
return;
}
// Add item to new collection
if (postId) {
await supabase
.from('collection_posts' as any)
.insert({
collection_id: data.id,
post_id: postId
});
} else if (pictureId) {
await supabase
.from('collection_pictures')
.insert({
collection_id: data.id,
picture_id: pictureId
});
}
setCollections(prev => [data, ...prev]);
setSelectedCollections(prev => new Set([...prev, data.id]));
setNewCollection({ name: '', description: '', is_public: true });
setShowCreateForm(false);
setLoading(false);
toast({
title: "Success",
description: "Collection created and photo added!"
});
};
const handleToggleCollection = async (collectionId: string) => {
if (!user) return;
const isSelected = selectedCollections.has(collectionId);
if (isSelected) {
// Remove from collection
if (postId) {
const { error } = await supabase
.from('collection_posts' as any)
.delete()
.eq('collection_id', collectionId)
.eq('post_id', postId);
if (error) console.error('Error removing post from collection:', error);
} else if (pictureId) {
const { error } = await supabase
.from('collection_pictures')
.delete()
.eq('collection_id', collectionId)
.eq('picture_id', pictureId);
if (error) console.error('Error removing picture from collection:', error);
}
setSelectedCollections(prev => {
const newSet = new Set(prev);
newSet.delete(collectionId);
return newSet;
});
} else {
// Add to collection
if (postId) {
const { error } = await supabase
.from('collection_posts' as any)
.insert({
collection_id: collectionId,
post_id: postId
});
if (error) console.error('Error adding post to collection:', error);
} else if (pictureId) {
const { error } = await supabase
.from('collection_pictures')
.insert({
collection_id: collectionId,
picture_id: pictureId
});
if (error) console.error('Error adding picture to collection:', error);
}
setSelectedCollections(prev => new Set([...prev, collectionId]));
}
};
const handleSave = () => {
toast({
title: "Saved",
description: "Collection preferences updated!"
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Add to Collection
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Create new collection */}
{!showCreateForm ? (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowCreateForm(true)}
>
<Plus className="h-4 w-4 mr-2" />
Create New Collection
</Button>
) : (
<Card>
<CardContent className="p-4 space-y-3">
<Input
placeholder="Collection name"
value={newCollection.name}
onChange={(e) => setNewCollection(prev => ({ ...prev, name: e.target.value }))}
/>
<Textarea
placeholder="Description (optional)"
value={newCollection.description}
onChange={(e) => setNewCollection(prev => ({ ...prev, description: e.target.value }))}
rows={2}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="public"
checked={newCollection.is_public}
onCheckedChange={(checked) =>
setNewCollection(prev => ({ ...prev, is_public: checked as boolean }))
}
/>
<label htmlFor="public" className="text-sm">Make public</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreateCollection}
disabled={!newCollection.name.trim() || loading}
>
Create
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Existing collections */}
<div className="space-y-2 max-h-60 overflow-y-auto">
{collections.map((collection) => (
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
onClick={() => handleToggleCollection(collection.id)}
>
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{collection.name}</h4>
{collection.description && (
<p className="text-sm text-muted-foreground">
{collection.description}
</p>
)}
</div>
<Checkbox
checked={selectedCollections.has(collection.id)}
disabled
/>
</div>
</CardContent>
</Card>
))}
</div>
{collections.length === 0 && !showCreateForm && (
<p className="text-center text-muted-foreground py-4">
No collections yet. Create your first one!
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>
Done
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AddToCollectionModal;

View File

@ -169,4 +169,4 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
);
};
export default CollapsibleSection;
export default CollapsibleSection;

View File

@ -1,49 +1,49 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Bookmark } from 'lucide-react';
import AddToCollectionModal from './AddToCollectionModal';
import { useAuth } from '@/hooks/useAuth';
interface CollectionButtonProps {
pictureId: string;
size?: 'sm' | 'lg';
variant?: 'default' | 'ghost' | 'outline';
className?: string;
}
const CollectionButton = ({
pictureId,
size = 'sm',
variant = 'ghost',
className = ''
}: CollectionButtonProps) => {
const { user } = useAuth();
const [showModal, setShowModal] = useState(false);
if (!user) return null;
return (
<>
<Button
variant={variant}
size={size}
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
className={`transition-all duration-200 hover:scale-110 ${className}`}
title="Add to collection"
>
<Bookmark className={`${size === 'sm' ? 'h-3 w-3' : 'h-5 w-5'}`} />
</Button>
<AddToCollectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
pictureId={pictureId}
/>
</>
);
};
export default CollectionButton;
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Bookmark } from 'lucide-react';
import AddToCollectionModal from './AddToCollectionModal';
import { useAuth } from '@/hooks/useAuth';
interface CollectionButtonProps {
pictureId: string;
size?: 'sm' | 'lg';
variant?: 'default' | 'ghost' | 'outline';
className?: string;
}
const CollectionButton = ({
pictureId,
size = 'sm',
variant = 'ghost',
className = ''
}: CollectionButtonProps) => {
const { user } = useAuth();
const [showModal, setShowModal] = useState(false);
if (!user) return null;
return (
<>
<Button
variant={variant}
size={size}
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
className={`transition-all duration-200 hover:scale-110 ${className}`}
title="Add to collection"
>
<Bookmark className={`${size === 'sm' ? 'h-3 w-3' : 'h-5 w-5'}`} />
</Button>
<AddToCollectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
pictureId={pictureId}
/>
</>
);
};
export default CollectionButton;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,157 +1,157 @@
import MediaCard from "./MediaCard";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useFeedData } from "@/hooks/useFeedData";
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
import type { MediaItem } from "@/types";
import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
import { T } from '@/i18n';
import type { FeedSortOption } from '@/hooks/useFeedData';
import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts";
interface GalleryLargeProps {
customPictures?: any[];
customLoading?: boolean;
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'search';
navigationSourceId?: string;
sortBy?: FeedSortOption;
categorySlugs?: string[];
categoryIds?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
}
const GalleryLarge = ({
customPictures,
customLoading,
navigationSource = 'home',
navigationSourceId,
sortBy = 'latest',
categorySlugs,
categoryIds,
contentType,
visibilityFilter,
center
}: GalleryLargeProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const { orgSlug, isOrgContext } = useOrganization();
const [mediaItems, setMediaItems] = useState<any[]>([]);
const [userLikes, setUserLikes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
// 1. Data Fetching
const { posts: feedPosts, loading: feedLoading } = useFeedData({
source: navigationSource,
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy,
categorySlugs,
categoryIds,
contentType,
visibilityFilter,
// Disable hook if we have custom pictures
enabled: !customPictures
});
// 2. State & Effects
useEffect(() => {
let finalMedia: any[] = [];
if (customPictures) {
finalMedia = customPictures;
setLoading(customLoading || false);
} else {
// Map FeedPost[] -> MediaItemType[]
finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy);
setLoading(feedLoading);
}
setMediaItems(finalMedia || []);
}, [feedPosts, feedLoading, customPictures, customLoading, sortBy]);
const refreshUserLikes = useCallback(async () => {
if (!user || mediaItems.length === 0) return;
try {
const { pictureLikes } = await fetchUserMediaLikes(user.id);
setUserLikes(pictureLikes);
} catch (error) {
console.error('Error fetching user likes:', error);
}
}, [user, mediaItems.length]);
// Fetch likes on mount and whenever the media list changes
useEffect(() => {
refreshUserLikes();
}, [refreshUserLikes]);
const handleError = () => {
window.location.reload();
}
if (loading) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
<T>Loading gallery...</T>
</div>
</div>
);
}
if (!mediaItems || mediaItems.length === 0) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
<p className="text-lg"><T>No media yet!</T></p>
<p><T>Be the first to share content with the community.</T></p>
</div>
</div>
)
}
return (
<div className="w-full relative max-w-4xl mx-auto px-4 pb-20 pt-4">
<div className="flex flex-col gap-12">
{mediaItems.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const displayUrl = item.image_url; return (
<MediaCard
key={item.id}
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => navigate(`/post/${item.id}`)}
onLike={refreshUserLikes}
onDelete={handleError}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
variant="feed"
/>
);
})}
</div>
</div>
);
};
export default GalleryLarge;
import MediaCard from "./MediaCard";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useFeedData } from "@/hooks/useFeedData";
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
import type { MediaItem } from "@/types";
import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
import { T } from '@/i18n';
import type { FeedSortOption } from '@/hooks/useFeedData';
import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts";
interface GalleryLargeProps {
customPictures?: any[];
customLoading?: boolean;
navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'search';
navigationSourceId?: string;
sortBy?: FeedSortOption;
categorySlugs?: string[];
categoryIds?: string[];
contentType?: 'posts' | 'pages' | 'pictures' | 'files';
visibilityFilter?: 'invisible' | 'private';
center?: boolean;
}
const GalleryLarge = ({
customPictures,
customLoading,
navigationSource = 'home',
navigationSourceId,
sortBy = 'latest',
categorySlugs,
categoryIds,
contentType,
visibilityFilter,
center
}: GalleryLargeProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const { orgSlug, isOrgContext } = useOrganization();
const [mediaItems, setMediaItems] = useState<any[]>([]);
const [userLikes, setUserLikes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
// 1. Data Fetching
const { posts: feedPosts, loading: feedLoading } = useFeedData({
source: navigationSource,
sourceId: navigationSourceId,
isOrgContext,
orgSlug,
sortBy,
categorySlugs,
categoryIds,
contentType,
visibilityFilter,
// Disable hook if we have custom pictures
enabled: !customPictures
});
// 2. State & Effects
useEffect(() => {
let finalMedia: any[] = [];
if (customPictures) {
finalMedia = customPictures;
setLoading(customLoading || false);
} else {
// Map FeedPost[] -> MediaItemType[]
finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy);
setLoading(feedLoading);
}
setMediaItems(finalMedia || []);
}, [feedPosts, feedLoading, customPictures, customLoading, sortBy]);
const refreshUserLikes = useCallback(async () => {
if (!user || mediaItems.length === 0) return;
try {
const { pictureLikes } = await fetchUserMediaLikes(user.id);
setUserLikes(pictureLikes);
} catch (error) {
console.error('Error fetching user likes:', error);
}
}, [user, mediaItems.length]);
// Fetch likes on mount and whenever the media list changes
useEffect(() => {
refreshUserLikes();
}, [refreshUserLikes]);
const handleError = () => {
window.location.reload();
}
if (loading) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
<T>Loading gallery...</T>
</div>
</div>
);
}
if (!mediaItems || mediaItems.length === 0) {
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
<p className="text-lg"><T>No media yet!</T></p>
<p><T>Be the first to share content with the community.</T></p>
</div>
</div>
)
}
return (
<div className="w-full relative max-w-4xl mx-auto px-4 pb-20 pt-4">
<div className="flex flex-col gap-12">
{mediaItems.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const displayUrl = item.image_url; return (
<MediaCard
key={item.id}
id={item.id}
pictureId={item.picture_id}
url={displayUrl}
thumbnailUrl={item.thumbnail_url}
title={item.title}
author={undefined as any}
authorAvatarUrl={undefined}
authorId={item.user_id}
likes={item.likes_count || 0}
comments={item.comments[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={itemType}
meta={item.meta}
onClick={() => navigate(`/post/${item.id}`)}
onLike={refreshUserLikes}
onDelete={handleError}
created_at={item.created_at}
job={item.job}
responsive={item.responsive}
variant="feed"
/>
);
})}
</div>
</div>
);
};
export default GalleryLarge;

View File

@ -1,52 +1,52 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { parseHashtagContent, type ContentSegment } from '@/utils/tagUtils';
interface HashtagTextProps {
children: string;
className?: string;
onTagClick?: (tag: string) => void;
}
const HashtagText: React.FC<HashtagTextProps> = ({
children,
className = '',
onTagClick
}) => {
const navigate = useNavigate();
const handleTagClick = (tag: string) => {
if (onTagClick) {
onTagClick(tag);
} else {
navigate(`/tags/${tag}`);
}
};
const segments = parseHashtagContent(children);
return (
<span className={className}>
{segments.map((segment: ContentSegment, index: number) => {
if (segment.type === 'hashtag') {
return (
<button
key={`tag-${index}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleTagClick(segment.content);
}}
className="text-primary hover:text-primary/80 font-medium hover:underline cursor-pointer transition-colors"
>
#{segment.content}
</button>
);
}
return <span key={`text-${index}`}>{segment.content}</span>;
})}
</span>
);
};
export default HashtagText;
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { parseHashtagContent, type ContentSegment } from '@/utils/tagUtils';
interface HashtagTextProps {
children: string;
className?: string;
onTagClick?: (tag: string) => void;
}
const HashtagText: React.FC<HashtagTextProps> = ({
children,
className = '',
onTagClick
}) => {
const navigate = useNavigate();
const handleTagClick = (tag: string) => {
if (onTagClick) {
onTagClick(tag);
} else {
navigate(`/tags/${tag}`);
}
};
const segments = parseHashtagContent(children);
return (
<span className={className}>
{segments.map((segment: ContentSegment, index: number) => {
if (segment.type === 'hashtag') {
return (
<button
key={`tag-${index}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleTagClick(segment.content);
}}
className="text-primary hover:text-primary/80 font-medium hover:underline cursor-pointer transition-colors"
>
#{segment.content}
</button>
);
}
return <span key={`text-${index}`}>{segment.content}</span>;
})}
</span>
);
};
export default HashtagText;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
// Central exports for ImageWizard UI components
export { QuickActionsToolbar } from './QuickActionsToolbar';
export { ImageActionButtons } from './ImageActionButtons';
export { ModelSelector } from './ModelSelector';
export { ModelSelectorPanel } from './ModelSelectorPanel';
export { default as WizardSidebar } from './WizardSidebar';
export { default as Prompt } from './Prompt';
export { default as ImageGalleryPanel } from './ImageGalleryPanel';
// Central exports for ImageWizard UI components
export { QuickActionsToolbar } from './QuickActionsToolbar';
export { ImageActionButtons } from './ImageActionButtons';
export { ModelSelector } from './ModelSelector';
export { ModelSelectorPanel } from './ModelSelectorPanel';
export { default as WizardSidebar } from './WizardSidebar';
export { default as Prompt } from './Prompt';
export { default as ImageGalleryPanel } from './ImageGalleryPanel';

View File

@ -1,141 +1,141 @@
import { ImageFile } from '../types';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { Logger } from '../utils/logger';
import { fetchPictureById, fetchVersions, fetchRecentPictures } from '@/modules/posts/client-pictures';
/**
* Data Loading & Saving Handlers
* - Load family versions
* - Load available images
*/
export const loadFamilyVersions = async (
parentImages: ImageFile[],
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
logger: Logger
) => {
try {
// Get all parent IDs and image IDs to find complete families
const imageIds = parentImages.map(img => img.realDatabaseId || img.id).filter(id => id && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i));
if (imageIds.length === 0) {
return;
}
// For each image, find its complete family tree via API
const allFamilyImages = new Set<string>();
const allVersionData: any[] = [];
for (const imageId of imageIds) {
// First, get the current image details to check if it has a parent
const currentImage = await fetchPictureById(imageId);
if (!currentImage) {
console.error('🔧 [Wizard] Error loading current image:', imageId);
continue;
}
// Load all versions in this family via API
const familyVersions = await fetchVersions(currentImage as any);
if (!familyVersions || !Array.isArray(familyVersions)) {
console.error('🔧 [Wizard] Error loading family versions for:', imageId);
continue;
}
// Add all family members to our set (excluding the initial image)
familyVersions.forEach((version: any) => {
if (version.id !== imageId) {
allFamilyImages.add(version.id);
allVersionData.push(version);
}
});
}
// Map to ImageFile format and update state
if (allFamilyImages.size > 0) {
// Deduplicate by id
const uniqueVersions = new Map<string, any>();
allVersionData.forEach(v => uniqueVersions.set(v.id, v));
const versions = Array.from(uniqueVersions.values());
const versionImages: ImageFile[] = versions.map(version => ({
id: version.id,
src: version.image_url,
title: version.title,
userId: version.user_id,
selected: false, // Default to not selected in UI
isPreferred: version.is_selected || false,
isGenerated: true,
aiText: version.description || undefined
}));
// Add versions to images, but also update existing images with correct selection status
setImages(prev => {
// Create a map of database selection status
const dbSelectionStatus = new Map();
// Check if this is a singleton family (only one version)
const isSingleton = versions.length === 1;
versions.forEach(version => {
// If explicitly selected in DB, OR if it's the only version in the family
const isPreferred = version.is_selected || (isSingleton && !version.parent_id);
dbSelectionStatus.set(version.id, isPreferred);
});
// Update existing images and add new ones
const existingIds = new Set(prev.map(img => img.id));
const updatedExisting = prev.map(img => {
if (dbSelectionStatus.has(img.id)) {
const dbSelected = dbSelectionStatus.get(img.id);
return { ...img, isPreferred: dbSelected };
}
return img;
});
// Add new images
const newImages = versionImages.filter(img => !existingIds.has(img.id)).map(img => {
if (dbSelectionStatus.has(img.id)) {
return { ...img, isPreferred: dbSelectionStatus.get(img.id) };
}
return img;
});
const finalImages = [...updatedExisting, ...newImages];
return finalImages;
});
}
} catch (error) {
console.error('🔧 [Wizard] Error loading family versions:', error);
toast.error(translate('Failed to load image versions'));
}
};
export const loadAvailableImages = async (
setAvailableImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
setLoadingImages: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoadingImages(true);
try {
// Load recent pictures via API
const pictures = await fetchRecentPictures(50);
const imageFiles: ImageFile[] = pictures.map((picture: any) => ({
id: picture.id,
src: picture.image_url,
title: picture.title,
userId: picture.user_id,
selected: false
}));
setAvailableImages(imageFiles);
} catch (error) {
console.error('Error loading images:', error);
toast.error(translate('Failed to load images'));
} finally {
setLoadingImages(false);
}
};
import { ImageFile } from '../types';
import { toast } from 'sonner';
import { translate } from '@/i18n';
import { Logger } from '../utils/logger';
import { fetchPictureById, fetchVersions, fetchRecentPictures } from '@/modules/posts/client-pictures';
/**
* Data Loading & Saving Handlers
* - Load family versions
* - Load available images
*/
export const loadFamilyVersions = async (
parentImages: ImageFile[],
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
logger: Logger
) => {
try {
// Get all parent IDs and image IDs to find complete families
const imageIds = parentImages.map(img => img.realDatabaseId || img.id).filter(id => id && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i));
if (imageIds.length === 0) {
return;
}
// For each image, find its complete family tree via API
const allFamilyImages = new Set<string>();
const allVersionData: any[] = [];
for (const imageId of imageIds) {
// First, get the current image details to check if it has a parent
const currentImage = await fetchPictureById(imageId);
if (!currentImage) {
console.error('🔧 [Wizard] Error loading current image:', imageId);
continue;
}
// Load all versions in this family via API
const familyVersions = await fetchVersions(currentImage as any);
if (!familyVersions || !Array.isArray(familyVersions)) {
console.error('🔧 [Wizard] Error loading family versions for:', imageId);
continue;
}
// Add all family members to our set (excluding the initial image)
familyVersions.forEach((version: any) => {
if (version.id !== imageId) {
allFamilyImages.add(version.id);
allVersionData.push(version);
}
});
}
// Map to ImageFile format and update state
if (allFamilyImages.size > 0) {
// Deduplicate by id
const uniqueVersions = new Map<string, any>();
allVersionData.forEach(v => uniqueVersions.set(v.id, v));
const versions = Array.from(uniqueVersions.values());
const versionImages: ImageFile[] = versions.map(version => ({
id: version.id,
src: version.image_url,
title: version.title,
userId: version.user_id,
selected: false, // Default to not selected in UI
isPreferred: version.is_selected || false,
isGenerated: true,
aiText: version.description || undefined
}));
// Add versions to images, but also update existing images with correct selection status
setImages(prev => {
// Create a map of database selection status
const dbSelectionStatus = new Map();
// Check if this is a singleton family (only one version)
const isSingleton = versions.length === 1;
versions.forEach(version => {
// If explicitly selected in DB, OR if it's the only version in the family
const isPreferred = version.is_selected || (isSingleton && !version.parent_id);
dbSelectionStatus.set(version.id, isPreferred);
});
// Update existing images and add new ones
const existingIds = new Set(prev.map(img => img.id));
const updatedExisting = prev.map(img => {
if (dbSelectionStatus.has(img.id)) {
const dbSelected = dbSelectionStatus.get(img.id);
return { ...img, isPreferred: dbSelected };
}
return img;
});
// Add new images
const newImages = versionImages.filter(img => !existingIds.has(img.id)).map(img => {
if (dbSelectionStatus.has(img.id)) {
return { ...img, isPreferred: dbSelectionStatus.get(img.id) };
}
return img;
});
const finalImages = [...updatedExisting, ...newImages];
return finalImages;
});
}
} catch (error) {
console.error('🔧 [Wizard] Error loading family versions:', error);
toast.error(translate('Failed to load image versions'));
}
};
export const loadAvailableImages = async (
setAvailableImages: React.Dispatch<React.SetStateAction<ImageFile[]>>,
setLoadingImages: React.Dispatch<React.SetStateAction<boolean>>
) => {
setLoadingImages(true);
try {
// Load recent pictures via API
const pictures = await fetchRecentPictures(50);
const imageFiles: ImageFile[] = pictures.map((picture: any) => ({
id: picture.id,
src: picture.image_url,
title: picture.title,
userId: picture.user_id,
selected: false
}));
setAvailableImages(imageFiles);
} catch (error) {
console.error('Error loading images:', error);
toast.error(translate('Failed to load images'));
} finally {
setLoadingImages(false);
}
};

View File

@ -1,117 +1,117 @@
import React from "react";
import { Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useWizardContext } from "@/hooks/useWizardContext";
import { toast } from "sonner";
import { translate } from "@/i18n";
interface MagicWizardButtonProps {
imageUrl: string;
imageTitle: string;
className?: string;
size?: "sm" | "default" | "lg" | "icon";
variant?: "default" | "ghost" | "outline";
onClick?: (e: React.MouseEvent) => void;
editingPostId?: string | null; // Explicitly pass the post ID if known, or null to prevent auto-detection
pictureId?: string; // Explicitly pass the picture ID if known
children?: React.ReactNode;
}
const MagicWizardButton: React.FC<MagicWizardButtonProps> = ({
imageUrl,
imageTitle,
className = "",
size = "sm",
variant = "ghost",
onClick,
editingPostId: explicitPostId,
pictureId: explicitPictureId,
children
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const { setWizardImage } = useWizardContext();
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onClick) {
onClick(e);
return;
}
if (!user) {
toast.error(translate('Please sign in to use the AI wizard'));
return;
}
// Get the real picture ID from the URL path if available
const urlPath = window.location.pathname;
// Check if we are in a post context
// If explicitPostId is provided (even as null), use it.
// If it is undefined, try to infer from URL.
let editingPostId: string | null = null;
if (explicitPostId !== undefined) {
editingPostId = explicitPostId;
} else {
const postMatch = urlPath.match(/\/post\/([a-f0-9-]{36})/);
if (postMatch) {
editingPostId = postMatch[1];
}
}
let realPictureId = explicitPictureId || null;
// Only try to guess from URL if explicitPictureId is not provided
if (!realPictureId) {
const pictureIdMatch = urlPath.match(/\/post\/([a-f0-9-]{36})/);
realPictureId = pictureIdMatch ? pictureIdMatch[1] : null;
}
const imageData = {
id: realPictureId || `external-${Date.now()}`,
src: imageUrl,
title: imageTitle,
selected: true,
realDatabaseId: realPictureId // Store the real database ID separately
};
// Store in Zustand (replaces sessionStorage) with return path
setWizardImage(imageData, window.location.pathname);
// Navigate to wizard
// Note: navigationData is now maintained by Zustand - no manual storage needed!
navigate('/wizard', {
state: {
mode: 'default', // Keep default mode but passing editingPostId allows "Add to Post"
editingPostId: editingPostId
}
});
};
// Don't render if user is not logged in
if (!user) {
return null;
}
return (
<Button
size={size}
variant={variant}
onClick={handleClick}
className={`${className?.includes('p-0')
? 'text-foreground hover:text-primary transition-colors'
: 'text-foreground hover:text-primary transition-colors'
} ${className}`}
title={translate("Edit with AI Wizard")}
>
<Wand2 className={`${size === 'sm' ? 'h-6 w-6' : 'h-5 w-5'} ${className?.includes('p-0') ? '' : 'mr-1'} drop-shadow-sm`} />
{children}
</Button>
);
};
export default MagicWizardButton;
import React from "react";
import { Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { useWizardContext } from "@/hooks/useWizardContext";
import { toast } from "sonner";
import { translate } from "@/i18n";
interface MagicWizardButtonProps {
imageUrl: string;
imageTitle: string;
className?: string;
size?: "sm" | "default" | "lg" | "icon";
variant?: "default" | "ghost" | "outline";
onClick?: (e: React.MouseEvent) => void;
editingPostId?: string | null; // Explicitly pass the post ID if known, or null to prevent auto-detection
pictureId?: string; // Explicitly pass the picture ID if known
children?: React.ReactNode;
}
const MagicWizardButton: React.FC<MagicWizardButtonProps> = ({
imageUrl,
imageTitle,
className = "",
size = "sm",
variant = "ghost",
onClick,
editingPostId: explicitPostId,
pictureId: explicitPictureId,
children
}) => {
const { user } = useAuth();
const navigate = useNavigate();
const { setWizardImage } = useWizardContext();
const handleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onClick) {
onClick(e);
return;
}
if (!user) {
toast.error(translate('Please sign in to use the AI wizard'));
return;
}
// Get the real picture ID from the URL path if available
const urlPath = window.location.pathname;
// Check if we are in a post context
// If explicitPostId is provided (even as null), use it.
// If it is undefined, try to infer from URL.
let editingPostId: string | null = null;
if (explicitPostId !== undefined) {
editingPostId = explicitPostId;
} else {
const postMatch = urlPath.match(/\/post\/([a-f0-9-]{36})/);
if (postMatch) {
editingPostId = postMatch[1];
}
}
let realPictureId = explicitPictureId || null;
// Only try to guess from URL if explicitPictureId is not provided
if (!realPictureId) {
const pictureIdMatch = urlPath.match(/\/post\/([a-f0-9-]{36})/);
realPictureId = pictureIdMatch ? pictureIdMatch[1] : null;
}
const imageData = {
id: realPictureId || `external-${Date.now()}`,
src: imageUrl,
title: imageTitle,
selected: true,
realDatabaseId: realPictureId // Store the real database ID separately
};
// Store in Zustand (replaces sessionStorage) with return path
setWizardImage(imageData, window.location.pathname);
// Navigate to wizard
// Note: navigationData is now maintained by Zustand - no manual storage needed!
navigate('/wizard', {
state: {
mode: 'default', // Keep default mode but passing editingPostId allows "Add to Post"
editingPostId: editingPostId
}
});
};
// Don't render if user is not logged in
if (!user) {
return null;
}
return (
<Button
size={size}
variant={variant}
onClick={handleClick}
className={`${className?.includes('p-0')
? 'text-foreground hover:text-primary transition-colors'
: 'text-foreground hover:text-primary transition-colors'
} ${className}`}
title={translate("Edit with AI Wizard")}
>
<Wand2 className={`${size === 'sm' ? 'h-6 w-6' : 'h-5 w-5'} ${className?.includes('p-0') ? '' : 'mr-1'} drop-shadow-sm`} />
{children}
</Button>
);
};
export default MagicWizardButton;

View File

@ -1,128 +1,128 @@
import React, { useRef, useState, useCallback } from 'react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder = "Enter description...",
className = "",
onKeyDown
}) => {
const [activeTab, setActiveTab] = useState<'editor' | 'raw'>('editor');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const pendingImageResolveRef = useRef<((url: string) => void) | null>(null);
// Handler for image selection from picker
const handleImageSelect = useCallback(async (pictureId: string) => {
try {
// Fetch the image URL via API
const data = await fetchPictureById(pictureId);
if (!data) throw new Error('Picture not found');
const imageUrl = data.image_url;
const resolveFunc = pendingImageResolveRef.current;
if (resolveFunc) {
pendingImageResolveRef.current = null;
resolveFunc(imageUrl);
}
setImagePickerOpen(false);
} catch (error) {
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;
}
setImagePickerOpen(false);
}
}, []);
const handleRawChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
}, [onChange]);
return (
<>
<div className={`border rounded-md bg-background ${className}`}>
<div className="flex border-b">
<button
type="button"
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Editor
</button>
<button
type="button"
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Markdown
</button>
</div>
{activeTab === 'editor' && (
<React.Suspense fallback={<div className="p-3 text-muted-foreground">Loading editor...</div>}>
<div></div>
</React.Suspense>
)}
{activeTab === 'raw' && (
<textarea
value={value || ''}
onChange={handleRawChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
className="w-full p-3 bg-transparent border-0 rounded-b-md focus:ring-0 focus:outline-none resize-none font-mono text-sm"
style={{ height: '120px', minHeight: '120px' }}
aria-label="Raw markdown input"
autoFocus
/>
)}
</div>
{/* Image Picker Dialog */}
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
// Reject the promise if closed without selection
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;
}
}}
onSelect={handleImageSelect}
currentValue={null}
/>
</>
);
};
MarkdownEditor.displayName = 'MarkdownEditor';
// Memoize with custom comparison
export default React.memo(MarkdownEditor, (prevProps, nextProps) => {
// Re-render if value, placeholder, className, or onKeyDown change
return (
prevProps.value === nextProps.value &&
prevProps.placeholder === nextProps.placeholder &&
prevProps.className === nextProps.className &&
prevProps.onKeyDown === nextProps.onKeyDown
// onChange is intentionally omitted to prevent unnecessary re-renders
);
});
import React, { useRef, useState, useCallback } from 'react';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder = "Enter description...",
className = "",
onKeyDown
}) => {
const [activeTab, setActiveTab] = useState<'editor' | 'raw'>('editor');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const pendingImageResolveRef = useRef<((url: string) => void) | null>(null);
// Handler for image selection from picker
const handleImageSelect = useCallback(async (pictureId: string) => {
try {
// Fetch the image URL via API
const data = await fetchPictureById(pictureId);
if (!data) throw new Error('Picture not found');
const imageUrl = data.image_url;
const resolveFunc = pendingImageResolveRef.current;
if (resolveFunc) {
pendingImageResolveRef.current = null;
resolveFunc(imageUrl);
}
setImagePickerOpen(false);
} catch (error) {
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;
}
setImagePickerOpen(false);
}
}, []);
const handleRawChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
}, [onChange]);
return (
<>
<div className={`border rounded-md bg-background ${className}`}>
<div className="flex border-b">
<button
type="button"
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Editor
</button>
<button
type="button"
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Markdown
</button>
</div>
{activeTab === 'editor' && (
<React.Suspense fallback={<div className="p-3 text-muted-foreground">Loading editor...</div>}>
<div></div>
</React.Suspense>
)}
{activeTab === 'raw' && (
<textarea
value={value || ''}
onChange={handleRawChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
className="w-full p-3 bg-transparent border-0 rounded-b-md focus:ring-0 focus:outline-none resize-none font-mono text-sm"
style={{ height: '120px', minHeight: '120px' }}
aria-label="Raw markdown input"
autoFocus
/>
)}
</div>
{/* Image Picker Dialog */}
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
// Reject the promise if closed without selection
if (pendingImageResolveRef.current) {
pendingImageResolveRef.current('');
pendingImageResolveRef.current = null;
}
}}
onSelect={handleImageSelect}
currentValue={null}
/>
</>
);
};
MarkdownEditor.displayName = 'MarkdownEditor';
// Memoize with custom comparison
export default React.memo(MarkdownEditor, (prevProps, nextProps) => {
// Re-render if value, placeholder, className, or onKeyDown change
return (
prevProps.value === nextProps.value &&
prevProps.placeholder === nextProps.placeholder &&
prevProps.className === nextProps.className &&
prevProps.onKeyDown === nextProps.onKeyDown
// onChange is intentionally omitted to prevent unnecessary re-renders
);
});

View File

@ -1,316 +1,316 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { htmlTableToMarkdown } from '@/lib/htmlTableToMarkdown';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
import { toast } from 'sonner';
import { translate } from '@/i18n';
// Lazy load the heavy editor component
const MDXEditorInternal = React.lazy(() => import('@/components/lazy-editors/MDXEditorInternal'));
// ── Error Boundary for MDXEditor ─────────────────────────────────────────
interface ErrorBoundaryProps {
children: React.ReactNode;
value: string;
onChange: (v: string) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class MDXEditorErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error) {
console.error('[MDXEditor] Parse error caught by boundary:', error);
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/30 text-destructive text-sm">
<svg className="h-4 w-4 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm-.75 3.75a.75.75 0 0 1 1.5 0v4a.75.75 0 0 1-1.5 0v-4zM8 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
<span>
The editor could not parse this content (likely contains invalid characters like bare <code>&lt;</code>).
Edit below or switch to the <strong>Markdown</strong> tab.
</span>
</div>
<textarea
value={this.props.value || ''}
onChange={(e) => this.props.onChange(e.target.value)}
className="w-full min-h-[200px] bg-transparent border rounded-md p-3 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none resize-y"
aria-label="Raw markdown (fallback)"
/>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Retry editor
</button>
</div>
);
}
return this.props.children;
}
}
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
onSelectionChange?: (selectedText: string) => void;
onSave?: () => void;
editorRef?: React.RefObject<any>;
}
interface MDXEditorWithImagePickerProps {
value: string;
onChange: (markdown: string) => void;
placeholder: string;
onRequestImage: () => Promise<string>;
onTextInsert: (text: string) => void;
onSelectionChange?: (selectedText: string) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onSave?: () => void;
editorRef: React.MutableRefObject<any>;
}
export function MDXEditorWithImagePicker(props: MDXEditorWithImagePickerProps) {
return (
<React.Suspense fallback={<div className="p-4 text-center text-muted-foreground">Loading editor...</div>}>
<MDXEditorErrorBoundary value={props.value} onChange={props.onChange}>
<MDXEditorInternal {...props} />
</MDXEditorErrorBoundary>
</React.Suspense>
);
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder = "Enter description...",
className = "",
onKeyDown,
onSelectionChange,
onSave,
editorRef: externalEditorRef
}) => {
const [activeTab, setActiveTab] = useState<'editor' | 'raw'>('editor');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const lastEmittedValue = useRef<string>(value);
const internalEditorRef = useRef<any>(null);
const editorRef = externalEditorRef || internalEditorRef;
const imageResolveRef = useRef<((url: string) => void) | null>(null);
const imageRejectRef = useRef<(() => void) | null>(null);
// Handle external value changes (e.g., from filter panel)
useEffect(() => {
if (value !== lastEmittedValue.current) {
lastEmittedValue.current = value;
// Force update the MDXEditor if it exists
if (editorRef.current && activeTab === 'editor') {
try {
editorRef.current.setMarkdown(value);
} catch (error) {
console.warn('Failed to update MDXEditor:', error);
toast.warning(
translate('Content may contain characters that the editor cannot display. Switch to the Markdown tab to review.'),
{ duration: 5000 },
);
}
}
}
}, [value, activeTab]);
const handleEditorChange = useCallback((markdown: string) => {
// Only call onChange if content actually changed
if (markdown !== lastEmittedValue.current) {
lastEmittedValue.current = markdown;
onChange(markdown);
}
}, [onChange]);
const handleTextInsert = useCallback((text: string) => {
// Append transcribed text to current markdown
const currentMarkdown = lastEmittedValue.current || '';
const newMarkdown = currentMarkdown
? `${currentMarkdown}\n\n${text}`
: text;
lastEmittedValue.current = newMarkdown;
onChange(newMarkdown);
// Force update the editor
if (editorRef.current) {
editorRef.current.setMarkdown(newMarkdown);
}
}, [onChange]);
const handleToggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// This function returns a promise that resolves with the image URL
const handleRequestImage = useCallback((): Promise<string> => {
return new Promise((resolve, reject) => {
imageResolveRef.current = resolve;
imageRejectRef.current = reject;
setImagePickerOpen(true);
});
}, []);
const handleImageSelect = useCallback(async (pictureId: string) => {
// Immediately clear reject ref so onClose (which fires right after) won't reject the promise
imageRejectRef.current = null;
try {
console.log('[ImagePicker] Selected pictureId:', pictureId);
const data = await fetchPictureById(pictureId);
console.log('[ImagePicker] Fetched data:', data);
if (!data) throw new Error('Picture not found');
const imageUrl = data.image_url;
// Resolve the promise with the image URL
if (imageResolveRef.current) {
imageResolveRef.current(imageUrl);
imageResolveRef.current = null;
}
setImagePickerOpen(false);
} catch (error) {
console.error('[ImagePicker] Error selecting image:', error);
// Resolve with empty to avoid unhandled rejection
if (imageResolveRef.current) {
imageResolveRef.current('');
imageResolveRef.current = null;
}
setImagePickerOpen(false);
}
}, []);
const handleRawChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
lastEmittedValue.current = newValue;
onChange(newValue);
}, [onChange]);
// ── Clipboard: intercept HTML table paste → markdown table ─────────────
const handlePasteCapture = useCallback((e: React.ClipboardEvent) => {
console.log('[paste] capture fired', e.clipboardData.types);
const html = e.clipboardData?.getData('text/html');
if (!html) return;
const md = htmlTableToMarkdown(html);
if (!md) return; // no <table> → fall through to default
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
// MDXEditor rich tab: use ref API
if (editorRef.current?.insertMarkdown) {
editorRef.current.insertMarkdown(md);
} else {
// Raw textarea fallback: append at cursor / end
const newValue = lastEmittedValue.current
? `${lastEmittedValue.current}\n\n${md}`
: md;
lastEmittedValue.current = newValue;
onChange(newValue);
}
}, [editorRef, onChange]);
return (
<>
<div onPasteCapture={handlePasteCapture} className={`flex flex-col bg-background ${className} ${isFullscreen ? '' : 'border rounded-md'}`}>
<div className="flex border-b flex-shrink-0">
<button
type="button"
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Editor
</button>
<button
type="button"
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Markdown
</button>
</div>
{activeTab === 'editor' && (
<div className={`mdx-editor-wrapper flex-1 overflow-y-auto ${isFullscreen ? '' : 'min-h-[120px]'}`}>
<MDXEditorWithImagePicker
value={value}
onChange={handleEditorChange}
placeholder={placeholder}
onRequestImage={handleRequestImage}
onTextInsert={handleTextInsert}
onSelectionChange={onSelectionChange}
isFullscreen={isFullscreen}
onToggleFullscreen={handleToggleFullscreen}
onSave={onSave}
editorRef={editorRef}
/>
</div>
)}
{activeTab === 'raw' && (
<textarea
value={value || ''}
onChange={handleRawChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
className={`flex-1 w-full bg-transparent border-0 focus:ring-0 focus:outline-none resize-none font-mono text-sm ${isFullscreen ? '' : 'p-3 rounded-b-md min-h-[120px]'
}`}
aria-label="Raw markdown input"
autoFocus
/>
)}
</div>
{/* Image Picker Dialog */}
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
// Reject the promise if closed without selection
if (imageRejectRef.current) {
imageRejectRef.current();
imageRejectRef.current = null;
imageResolveRef.current = null;
}
}}
onSelect={handleImageSelect}
currentValue={null}
/>
</>
);
};
MarkdownEditor.displayName = 'MarkdownEditor';
// Memoize with default shallow comparison
export default React.memo(MarkdownEditor);
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { htmlTableToMarkdown } from '@/lib/htmlTableToMarkdown';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { fetchPictureById } from '@/modules/posts/client-pictures';
import { toast } from 'sonner';
import { translate } from '@/i18n';
// Lazy load the heavy editor component
const MDXEditorInternal = React.lazy(() => import('@/components/lazy-editors/MDXEditorInternal'));
// ── Error Boundary for MDXEditor ─────────────────────────────────────────
interface ErrorBoundaryProps {
children: React.ReactNode;
value: string;
onChange: (v: string) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class MDXEditorErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error) {
console.error('[MDXEditor] Parse error caught by boundary:', error);
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/30 text-destructive text-sm">
<svg className="h-4 w-4 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm-.75 3.75a.75.75 0 0 1 1.5 0v4a.75.75 0 0 1-1.5 0v-4zM8 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
<span>
The editor could not parse this content (likely contains invalid characters like bare <code>&lt;</code>).
Edit below or switch to the <strong>Markdown</strong> tab.
</span>
</div>
<textarea
value={this.props.value || ''}
onChange={(e) => this.props.onChange(e.target.value)}
className="w-full min-h-[200px] bg-transparent border rounded-md p-3 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none resize-y"
aria-label="Raw markdown (fallback)"
/>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Retry editor
</button>
</div>
);
}
return this.props.children;
}
}
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
onSelectionChange?: (selectedText: string) => void;
onSave?: () => void;
editorRef?: React.RefObject<any>;
}
interface MDXEditorWithImagePickerProps {
value: string;
onChange: (markdown: string) => void;
placeholder: string;
onRequestImage: () => Promise<string>;
onTextInsert: (text: string) => void;
onSelectionChange?: (selectedText: string) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onSave?: () => void;
editorRef: React.MutableRefObject<any>;
}
export function MDXEditorWithImagePicker(props: MDXEditorWithImagePickerProps) {
return (
<React.Suspense fallback={<div className="p-4 text-center text-muted-foreground">Loading editor...</div>}>
<MDXEditorErrorBoundary value={props.value} onChange={props.onChange}>
<MDXEditorInternal {...props} />
</MDXEditorErrorBoundary>
</React.Suspense>
);
}
const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
value,
onChange,
placeholder = "Enter description...",
className = "",
onKeyDown,
onSelectionChange,
onSave,
editorRef: externalEditorRef
}) => {
const [activeTab, setActiveTab] = useState<'editor' | 'raw'>('editor');
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const lastEmittedValue = useRef<string>(value);
const internalEditorRef = useRef<any>(null);
const editorRef = externalEditorRef || internalEditorRef;
const imageResolveRef = useRef<((url: string) => void) | null>(null);
const imageRejectRef = useRef<(() => void) | null>(null);
// Handle external value changes (e.g., from filter panel)
useEffect(() => {
if (value !== lastEmittedValue.current) {
lastEmittedValue.current = value;
// Force update the MDXEditor if it exists
if (editorRef.current && activeTab === 'editor') {
try {
editorRef.current.setMarkdown(value);
} catch (error) {
console.warn('Failed to update MDXEditor:', error);
toast.warning(
translate('Content may contain characters that the editor cannot display. Switch to the Markdown tab to review.'),
{ duration: 5000 },
);
}
}
}
}, [value, activeTab]);
const handleEditorChange = useCallback((markdown: string) => {
// Only call onChange if content actually changed
if (markdown !== lastEmittedValue.current) {
lastEmittedValue.current = markdown;
onChange(markdown);
}
}, [onChange]);
const handleTextInsert = useCallback((text: string) => {
// Append transcribed text to current markdown
const currentMarkdown = lastEmittedValue.current || '';
const newMarkdown = currentMarkdown
? `${currentMarkdown}\n\n${text}`
: text;
lastEmittedValue.current = newMarkdown;
onChange(newMarkdown);
// Force update the editor
if (editorRef.current) {
editorRef.current.setMarkdown(newMarkdown);
}
}, [onChange]);
const handleToggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// This function returns a promise that resolves with the image URL
const handleRequestImage = useCallback((): Promise<string> => {
return new Promise((resolve, reject) => {
imageResolveRef.current = resolve;
imageRejectRef.current = reject;
setImagePickerOpen(true);
});
}, []);
const handleImageSelect = useCallback(async (pictureId: string) => {
// Immediately clear reject ref so onClose (which fires right after) won't reject the promise
imageRejectRef.current = null;
try {
console.log('[ImagePicker] Selected pictureId:', pictureId);
const data = await fetchPictureById(pictureId);
console.log('[ImagePicker] Fetched data:', data);
if (!data) throw new Error('Picture not found');
const imageUrl = data.image_url;
// Resolve the promise with the image URL
if (imageResolveRef.current) {
imageResolveRef.current(imageUrl);
imageResolveRef.current = null;
}
setImagePickerOpen(false);
} catch (error) {
console.error('[ImagePicker] Error selecting image:', error);
// Resolve with empty to avoid unhandled rejection
if (imageResolveRef.current) {
imageResolveRef.current('');
imageResolveRef.current = null;
}
setImagePickerOpen(false);
}
}, []);
const handleRawChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
lastEmittedValue.current = newValue;
onChange(newValue);
}, [onChange]);
// ── Clipboard: intercept HTML table paste → markdown table ─────────────
const handlePasteCapture = useCallback((e: React.ClipboardEvent) => {
console.log('[paste] capture fired', e.clipboardData.types);
const html = e.clipboardData?.getData('text/html');
if (!html) return;
const md = htmlTableToMarkdown(html);
if (!md) return; // no <table> → fall through to default
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
// MDXEditor rich tab: use ref API
if (editorRef.current?.insertMarkdown) {
editorRef.current.insertMarkdown(md);
} else {
// Raw textarea fallback: append at cursor / end
const newValue = lastEmittedValue.current
? `${lastEmittedValue.current}\n\n${md}`
: md;
lastEmittedValue.current = newValue;
onChange(newValue);
}
}, [editorRef, onChange]);
return (
<>
<div onPasteCapture={handlePasteCapture} className={`flex flex-col bg-background ${className} ${isFullscreen ? '' : 'border rounded-md'}`}>
<div className="flex border-b flex-shrink-0">
<button
type="button"
onClick={() => setActiveTab('editor')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Editor
</button>
<button
type="button"
onClick={() => setActiveTab('raw')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'raw'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
Markdown
</button>
</div>
{activeTab === 'editor' && (
<div className={`mdx-editor-wrapper flex-1 overflow-y-auto ${isFullscreen ? '' : 'min-h-[120px]'}`}>
<MDXEditorWithImagePicker
value={value}
onChange={handleEditorChange}
placeholder={placeholder}
onRequestImage={handleRequestImage}
onTextInsert={handleTextInsert}
onSelectionChange={onSelectionChange}
isFullscreen={isFullscreen}
onToggleFullscreen={handleToggleFullscreen}
onSave={onSave}
editorRef={editorRef}
/>
</div>
)}
{activeTab === 'raw' && (
<textarea
value={value || ''}
onChange={handleRawChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
className={`flex-1 w-full bg-transparent border-0 focus:ring-0 focus:outline-none resize-none font-mono text-sm ${isFullscreen ? '' : 'p-3 rounded-b-md min-h-[120px]'
}`}
aria-label="Raw markdown input"
autoFocus
/>
)}
</div>
{/* Image Picker Dialog */}
<ImagePickerDialog
isOpen={imagePickerOpen}
onClose={() => {
setImagePickerOpen(false);
// Reject the promise if closed without selection
if (imageRejectRef.current) {
imageRejectRef.current();
imageRejectRef.current = null;
imageResolveRef.current = null;
}
}}
onSelect={handleImageSelect}
currentValue={null}
/>
</>
);
};
MarkdownEditor.displayName = 'MarkdownEditor';
// Memoize with default shallow comparison
export default React.memo(MarkdownEditor);

View File

@ -1,433 +1,433 @@
import React, { useMemo, useEffect, useRef, useState, Suspense, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import HashtagText from './HashtagText';
import Prism from 'prismjs';
import ResponsiveImage from './ResponsiveImage';
import { useAuth } from '@/hooks/useAuth';
// Import type from Post module
import { PostMediaItem } from '@/modules/posts/views/types';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-markup';
import '../styles/prism-custom-theme.css';
// Lazy load SmartLightbox to avoid circular deps or heavy bundle on initial load
const SmartLightbox = React.lazy(() => import('@/modules/posts/views/components/SmartLightbox'));
const GalleryWidget = React.lazy(() => import('./widgets/GalleryWidget'));
const MermaidWidget = React.lazy(() => import('./widgets/MermaidWidget'));
interface MarkdownRendererProps {
content: string;
className?: string;
variables?: Record<string, any>;
baseUrl?: string;
onLinkClick?: (href: string, e: React.MouseEvent<HTMLAnchorElement>) => void;
}
// Helper function to format URL display text (ported from previous implementation)
const formatUrlDisplay = (url: string): string => {
try {
// Remove protocol
let displayUrl = url.replace(/^https?:\/\//, '');
// Remove www. if present
displayUrl = displayUrl.replace(/^www\./, '');
// Truncate if too long (keep domain + some path)
if (displayUrl.length > 40) {
const parts = displayUrl.split('/');
const domain = parts[0];
const path = parts.slice(1).join('/');
if (path.length > 20) {
displayUrl = `${domain}/${path.substring(0, 15)}...`;
} else {
displayUrl = `${domain}/${path}`;
}
}
return displayUrl;
} catch {
return url;
}
};
// Helper for slugifying headings
const slugify = (text: string) => {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/** Recursively extract plain text from React children (handles nested <a>, <strong>, etc.) */
const getPlainText = (children: React.ReactNode): string => {
if (typeof children === 'string' || typeof children === 'number') return String(children);
if (Array.isArray(children)) return children.map(getPlainText).join('');
if (React.isValidElement(children)) return getPlainText((children.props as any).children);
return '';
};
import { substitute } from '@/lib/variables';
// Helper to strip YAML frontmatter
const stripFrontmatter = (text: string) => {
return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trimStart();
};
const MarkdownRenderer = React.memo(({ content, className = "", variables, baseUrl, onLinkClick }: MarkdownRendererProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { user } = useAuth();
// Helper to resolve relative URLs
const resolveUrl = useCallback((url: string | undefined) => {
if (!url) return '';
if (!baseUrl) return url;
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
// Resolve relative path against baseUrl
try {
// If baseUrl is relative, make it absolute using the API origin so the server can fetch it
let absoluteBase = baseUrl;
if (baseUrl.startsWith('/')) {
const apiOrigin = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
// if API url is absolute (http://...), use it as the base.
// fallback to window.location.origin for relative API configs.
const originToUse = apiOrigin.startsWith('http') ? apiOrigin : window.location.origin;
// Avoid double-prefixing if baseUrl already contains the origin root (e.g. from SSR)
if (!baseUrl.startsWith(originToUse)) {
absoluteBase = `${originToUse}${baseUrl}`;
}
}
// Ensure the base URL resolves to the directory, not the file
// URL constructor resolves './file' relative to the path. If path doesn't end in '/',
// it strips the last segment. So we DO NOT want to arbitrarily append '/' to a file path.
// If absoluteBase is a file path (e.g. '.../document.md'), the URL constructor natively handles:
// new URL('./image.jpg', '.../document.md') => '.../image.jpg'
return new URL(url, absoluteBase).href;
} catch {
return url; // Fallback if parsing fails
}
}, [baseUrl]);
// Substitute variables in content if provided
const finalContent = useMemo(() => {
const withoutFrontmatter = stripFrontmatter(content);
if (!variables || Object.keys(variables).length === 0) return withoutFrontmatter;
return substitute(false, withoutFrontmatter, variables);
}, [content, variables]);
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
// Extract all images from content for navigation
const allImages = useMemo(() => {
const images: { src: string, alt: string }[] = [];
const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
// We clone the regex to avoid stateful issues if reuse happens, though local var is fine
const localRegex = new RegExp(regex);
while ((match = localRegex.exec(finalContent)) !== null) {
images.push({
alt: match[1],
src: match[2]
});
}
return images;
}, [finalContent]);
// Memoize content analysis (keep existing logic for simple hashtag views)
const contentAnalysis = useMemo(() => {
const hasHashtags = /#[a-zA-Z0-9_]+/.test(finalContent);
const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(finalContent);
const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(finalContent);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
}, [finalContent]);
// Removed Prism.highlightAllUnder to prevent React NotFoundError during streaming
// Highlighting is now handled safely within the `code` component renderer.
const handleImageClick = (src: string) => {
const index = allImages.findIndex(img => img.src === src);
if (index !== -1) {
setCurrentImageIndex(index);
setLightboxOpen(true);
}
};
const handleNavigate = (direction: 'prev' | 'next') => {
if (direction === 'prev') {
setCurrentImageIndex(prev => (prev > 0 ? prev - 1 : prev));
} else {
setCurrentImageIndex(prev => (prev < allImages.length - 1 ? prev + 1 : prev));
}
};
// Mock MediaItem for SmartLightbox
const mockMediaItem = useMemo((): PostMediaItem | null => {
const selectedImage = allImages[currentImageIndex];
if (!selectedImage) return null;
const resolvedUrl = resolveUrl(selectedImage.src);
return {
id: 'md-' + btoa(encodeURIComponent(selectedImage.src)).substring(0, 10), // stable ID based on SRC
title: selectedImage.alt || 'Image',
description: '',
image_url: resolvedUrl,
thumbnail_url: resolvedUrl,
user_id: user?.id || 'unknown',
type: 'image',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
likes_count: 0,
post_id: null
} as any;
}, [currentImageIndex, allImages, user, resolveUrl]);
// Only use HashtagText if content has hashtags but NO markdown syntax at all
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
return (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{finalContent}</HashtagText>
</div>
);
}
return (
<>
<div
ref={containerRef}
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
img: ({ node, src, alt, title, ...props }) => {
// Basic implementation of ResponsiveImage
const resolvedSrc = resolveUrl(src);
return (
<span className="block my-4">
<ResponsiveImage
src={resolvedSrc}
alt={alt || ''}
title={title} // Pass title down if ResponsiveImage supports it or wrap it
className={`cursor-pointer ${props.className || ''}`}
imgClassName="cursor-pointer hover:opacity-95 transition-opacity"
// Default generous sizes for blog post content
sizes="(max-width: 768px) 100vw, 800px"
loading="lazy"
onClick={() => resolvedSrc && handleImageClick(src || '')}
/>
{title && <span className="block text-center text-sm text-muted-foreground mt-2 italic">{title}</span>}
</span>
);
},
a: ({ node, href, children, ...props }) => {
if (!href) return <span {...props}>{children}</span>;
// Logic to format display text if it matches the URL
let childText = '';
if (typeof children === 'string') {
childText = children;
} else if (Array.isArray(children) && children.length > 0 && typeof children[0] === 'string') {
// Simple approximation for React children
childText = children[0];
}
const isAutoLink = childText === href || childText.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, '');
const displayContent = isAutoLink ? formatUrlDisplay(href) : children;
const isRelative = !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:') && !href.startsWith('tel:') && !href.startsWith('data:') && !href.startsWith('#');
return (
<a
href={href}
target={isRelative ? undefined : "_blank"}
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline hover:no-underline transition-colors"
onClick={(e) => {
if (onLinkClick) {
onLinkClick(href, e);
}
}}
{...props}
>
{displayContent}
</a>
);
},
h1: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h1 id={id} {...props}>{children}</h1>;
},
h2: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h2 id={id} {...props}>{children}</h2>;
},
h3: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h3 id={id} {...props}>{children}</h3>;
},
h4: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h4 id={id} {...props}>{children}</h4>;
},
p: ({ node, children, ...props }) => {
// Check if the paragraph contains an image
// @ts-ignore
const hasImage = node?.children?.some((child: any) =>
child.type === 'element' && child.tagName === 'img'
);
if (hasImage) {
return <div {...props}>{children}</div>;
}
return <p {...props}>{children}</p>;
},
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-border" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-muted/50" {...props} />
),
th: ({ node, ...props }) => (
<th className="border border-border px-3 py-2 text-left text-sm font-semibold" {...props} />
),
td: ({ node, ...props }) => (
<td className="border border-border px-3 py-2 text-sm" {...props} />
),
// Custom component: ```custom-gallery\nid1,id2,id3\n```
code: ({ node, className, children, ...props }) => {
if (className === 'language-mermaid') {
const chart = String(children).trim();
return (
<Suspense fallback={<div className="animate-pulse h-32 bg-muted/20 border border-border/50 rounded-lg flex items-center justify-center my-6 text-sm text-muted-foreground">Loading Mermaid diagram...</div>}>
<MermaidWidget chart={chart} />
</Suspense>
);
}
if (className === 'language-custom-gallery') {
const ids = String(children).trim().split(/[,\s\n]+/).filter(Boolean);
if (ids.length > 0) {
return (
<Suspense fallback={<div className="animate-pulse h-48 bg-muted rounded" />}>
<GalleryWidget pictureIds={ids} thumbnailLayout="grid" imageFit="cover" />
</Suspense>
);
}
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (!match) {
// Inline code or unclassified code
return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm bg-muted/30 px-1 py-0.5 rounded`} {...props}>{children}</code>;
}
const text = String(children).replace(/\n$/, '');
// Handle common language aliases
let prismLang = language;
if (language === 'ts') prismLang = 'typescript';
if (language === 'js') prismLang = 'javascript';
if (language === 'sh') prismLang = 'bash';
if (language === 'html' || language === 'xml') prismLang = 'markup';
if (Prism.languages[prismLang]) {
try {
const html = Prism.highlight(text, Prism.languages[prismLang], prismLang);
return (
<code
className={`${className} whitespace-pre-wrap font-mono text-sm`}
dangerouslySetInnerHTML={{ __html: html }}
{...props}
// Avoid passing children when using dangerouslySetInnerHTML
children={undefined}
/>
);
} catch (e) {
console.error('Prism highlight error', e);
}
}
// Fallback to unhighlighted
return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm`} {...props}>{children}</code>;
},
// Unwrap <pre> for custom components (gallery etc.)
pre: ({ node, children, ...props }) => {
// Check the actual AST node type to see if it's our custom gallery
const firstChild = node?.children?.[0];
if (firstChild?.type === 'element' && firstChild?.tagName === 'code') {
const isGallery = Array.isArray(firstChild.properties?.className)
&& firstChild.properties?.className.includes('language-custom-gallery');
const isMermaid = Array.isArray(firstChild.properties?.className)
&& firstChild.properties?.className.includes('language-mermaid');
if (isGallery || isMermaid) {
return <>{children}</>;
}
// Normal code block
return <pre className={`${props.className || ''} whitespace-pre-wrap break-words overflow-x-auto p-4 rounded-lg bg-muted/50 border border-border/50 mt-4 mb-4`} {...props}>{children}</pre>;
}
// Fallback
return <pre {...props}>{children}</pre>;
},
}}
>
{finalContent}
</ReactMarkdown>
</div>
{lightboxOpen && mockMediaItem && (
<Suspense fallback={null}>
<SmartLightbox
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
mediaItem={mockMediaItem}
imageUrl={mockMediaItem.image_url}
imageTitle={mockMediaItem.title}
user={user}
isVideo={false}
// Dummy handlers for actions that aren't supported in this context
onPublish={async () => { }}
onNavigate={handleNavigate}
onOpenInWizard={() => { }}
currentIndex={currentImageIndex}
totalCount={allImages.length}
/>
</Suspense>
)}
</>
);
});
MarkdownRenderer.displayName = 'MarkdownRenderer';
export default MarkdownRenderer;
import React, { useMemo, useEffect, useRef, useState, Suspense, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import HashtagText from './HashtagText';
import Prism from 'prismjs';
import ResponsiveImage from './ResponsiveImage';
import { useAuth } from '@/hooks/useAuth';
// Import type from Post module
import { PostMediaItem } from '@/modules/posts/views/types';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-markup';
import '../styles/prism-custom-theme.css';
// Lazy load SmartLightbox to avoid circular deps or heavy bundle on initial load
const SmartLightbox = React.lazy(() => import('@/modules/posts/views/components/SmartLightbox'));
const GalleryWidget = React.lazy(() => import('./widgets/GalleryWidget'));
const MermaidWidget = React.lazy(() => import('./widgets/MermaidWidget'));
interface MarkdownRendererProps {
content: string;
className?: string;
variables?: Record<string, any>;
baseUrl?: string;
onLinkClick?: (href: string, e: React.MouseEvent<HTMLAnchorElement>) => void;
}
// Helper function to format URL display text (ported from previous implementation)
const formatUrlDisplay = (url: string): string => {
try {
// Remove protocol
let displayUrl = url.replace(/^https?:\/\//, '');
// Remove www. if present
displayUrl = displayUrl.replace(/^www\./, '');
// Truncate if too long (keep domain + some path)
if (displayUrl.length > 40) {
const parts = displayUrl.split('/');
const domain = parts[0];
const path = parts.slice(1).join('/');
if (path.length > 20) {
displayUrl = `${domain}/${path.substring(0, 15)}...`;
} else {
displayUrl = `${domain}/${path}`;
}
}
return displayUrl;
} catch {
return url;
}
};
// Helper for slugifying headings
const slugify = (text: string) => {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/** Recursively extract plain text from React children (handles nested <a>, <strong>, etc.) */
const getPlainText = (children: React.ReactNode): string => {
if (typeof children === 'string' || typeof children === 'number') return String(children);
if (Array.isArray(children)) return children.map(getPlainText).join('');
if (React.isValidElement(children)) return getPlainText((children.props as any).children);
return '';
};
import { substitute } from '@/lib/variables';
// Helper to strip YAML frontmatter
const stripFrontmatter = (text: string) => {
return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trimStart();
};
const MarkdownRenderer = React.memo(({ content, className = "", variables, baseUrl, onLinkClick }: MarkdownRendererProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { user } = useAuth();
// Helper to resolve relative URLs
const resolveUrl = useCallback((url: string | undefined) => {
if (!url) return '';
if (!baseUrl) return url;
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
// Resolve relative path against baseUrl
try {
// If baseUrl is relative, make it absolute using the API origin so the server can fetch it
let absoluteBase = baseUrl;
if (baseUrl.startsWith('/')) {
const apiOrigin = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
// if API url is absolute (http://...), use it as the base.
// fallback to window.location.origin for relative API configs.
const originToUse = apiOrigin.startsWith('http') ? apiOrigin : window.location.origin;
// Avoid double-prefixing if baseUrl already contains the origin root (e.g. from SSR)
if (!baseUrl.startsWith(originToUse)) {
absoluteBase = `${originToUse}${baseUrl}`;
}
}
// Ensure the base URL resolves to the directory, not the file
// URL constructor resolves './file' relative to the path. If path doesn't end in '/',
// it strips the last segment. So we DO NOT want to arbitrarily append '/' to a file path.
// If absoluteBase is a file path (e.g. '.../document.md'), the URL constructor natively handles:
// new URL('./image.jpg', '.../document.md') => '.../image.jpg'
return new URL(url, absoluteBase).href;
} catch {
return url; // Fallback if parsing fails
}
}, [baseUrl]);
// Substitute variables in content if provided
const finalContent = useMemo(() => {
const withoutFrontmatter = stripFrontmatter(content);
if (!variables || Object.keys(variables).length === 0) return withoutFrontmatter;
return substitute(false, withoutFrontmatter, variables);
}, [content, variables]);
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
// Extract all images from content for navigation
const allImages = useMemo(() => {
const images: { src: string, alt: string }[] = [];
const regex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
// We clone the regex to avoid stateful issues if reuse happens, though local var is fine
const localRegex = new RegExp(regex);
while ((match = localRegex.exec(finalContent)) !== null) {
images.push({
alt: match[1],
src: match[2]
});
}
return images;
}, [finalContent]);
// Memoize content analysis (keep existing logic for simple hashtag views)
const contentAnalysis = useMemo(() => {
const hasHashtags = /#[a-zA-Z0-9_]+/.test(finalContent);
const hasMarkdownLinks = /\[.*?\]\(.*?\)/.test(finalContent);
const hasMarkdownSyntax = /(\*\*|__|##?|###?|####?|#####?|######?|\*|\n\*|\n-|\n\d+\.)/.test(finalContent);
return {
hasHashtags,
hasMarkdownLinks,
hasMarkdownSyntax
};
}, [finalContent]);
// Removed Prism.highlightAllUnder to prevent React NotFoundError during streaming
// Highlighting is now handled safely within the `code` component renderer.
const handleImageClick = (src: string) => {
const index = allImages.findIndex(img => img.src === src);
if (index !== -1) {
setCurrentImageIndex(index);
setLightboxOpen(true);
}
};
const handleNavigate = (direction: 'prev' | 'next') => {
if (direction === 'prev') {
setCurrentImageIndex(prev => (prev > 0 ? prev - 1 : prev));
} else {
setCurrentImageIndex(prev => (prev < allImages.length - 1 ? prev + 1 : prev));
}
};
// Mock MediaItem for SmartLightbox
const mockMediaItem = useMemo((): PostMediaItem | null => {
const selectedImage = allImages[currentImageIndex];
if (!selectedImage) return null;
const resolvedUrl = resolveUrl(selectedImage.src);
return {
id: 'md-' + btoa(encodeURIComponent(selectedImage.src)).substring(0, 10), // stable ID based on SRC
title: selectedImage.alt || 'Image',
description: '',
image_url: resolvedUrl,
thumbnail_url: resolvedUrl,
user_id: user?.id || 'unknown',
type: 'image',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
position: 0,
likes_count: 0,
post_id: null
} as any;
}, [currentImageIndex, allImages, user, resolveUrl]);
// Only use HashtagText if content has hashtags but NO markdown syntax at all
if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) {
return (
<div className={`prose prose-sm max-w-none dark:prose-invert ${className}`}>
<HashtagText>{finalContent}</HashtagText>
</div>
);
}
return (
<>
<div
ref={containerRef}
className={`prose prose-sm max-w-none dark:prose-invert ${className}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
img: ({ node, src, alt, title, ...props }) => {
// Basic implementation of ResponsiveImage
const resolvedSrc = resolveUrl(src);
return (
<span className="block my-4">
<ResponsiveImage
src={resolvedSrc}
alt={alt || ''}
title={title} // Pass title down if ResponsiveImage supports it or wrap it
className={`cursor-pointer ${props.className || ''}`}
imgClassName="cursor-pointer hover:opacity-95 transition-opacity"
// Default generous sizes for blog post content
sizes="(max-width: 768px) 100vw, 800px"
loading="lazy"
onClick={() => resolvedSrc && handleImageClick(src || '')}
/>
{title && <span className="block text-center text-sm text-muted-foreground mt-2 italic">{title}</span>}
</span>
);
},
a: ({ node, href, children, ...props }) => {
if (!href) return <span {...props}>{children}</span>;
// Logic to format display text if it matches the URL
let childText = '';
if (typeof children === 'string') {
childText = children;
} else if (Array.isArray(children) && children.length > 0 && typeof children[0] === 'string') {
// Simple approximation for React children
childText = children[0];
}
const isAutoLink = childText === href || childText.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, '');
const displayContent = isAutoLink ? formatUrlDisplay(href) : children;
const isRelative = !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:') && !href.startsWith('tel:') && !href.startsWith('data:') && !href.startsWith('#');
return (
<a
href={href}
target={isRelative ? undefined : "_blank"}
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline hover:no-underline transition-colors"
onClick={(e) => {
if (onLinkClick) {
onLinkClick(href, e);
}
}}
{...props}
>
{displayContent}
</a>
);
},
h1: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h1 id={id} {...props}>{children}</h1>;
},
h2: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h2 id={id} {...props}>{children}</h2>;
},
h3: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h3 id={id} {...props}>{children}</h3>;
},
h4: ({ node, children, ...props }) => {
const text = getPlainText(children);
const id = slugify(text);
return <h4 id={id} {...props}>{children}</h4>;
},
p: ({ node, children, ...props }) => {
// Check if the paragraph contains an image
// @ts-ignore
const hasImage = node?.children?.some((child: any) =>
child.type === 'element' && child.tagName === 'img'
);
if (hasImage) {
return <div {...props}>{children}</div>;
}
return <p {...props}>{children}</p>;
},
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-border" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-muted/50" {...props} />
),
th: ({ node, ...props }) => (
<th className="border border-border px-3 py-2 text-left text-sm font-semibold" {...props} />
),
td: ({ node, ...props }) => (
<td className="border border-border px-3 py-2 text-sm" {...props} />
),
// Custom component: ```custom-gallery\nid1,id2,id3\n```
code: ({ node, className, children, ...props }) => {
if (className === 'language-mermaid') {
const chart = String(children).trim();
return (
<Suspense fallback={<div className="animate-pulse h-32 bg-muted/20 border border-border/50 rounded-lg flex items-center justify-center my-6 text-sm text-muted-foreground">Loading Mermaid diagram...</div>}>
<MermaidWidget chart={chart} />
</Suspense>
);
}
if (className === 'language-custom-gallery') {
const ids = String(children).trim().split(/[,\s\n]+/).filter(Boolean);
if (ids.length > 0) {
return (
<Suspense fallback={<div className="animate-pulse h-48 bg-muted rounded" />}>
<GalleryWidget pictureIds={ids} thumbnailLayout="grid" imageFit="cover" />
</Suspense>
);
}
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
if (!match) {
// Inline code or unclassified code
return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm bg-muted/30 px-1 py-0.5 rounded`} {...props}>{children}</code>;
}
const text = String(children).replace(/\n$/, '');
// Handle common language aliases
let prismLang = language;
if (language === 'ts') prismLang = 'typescript';
if (language === 'js') prismLang = 'javascript';
if (language === 'sh') prismLang = 'bash';
if (language === 'html' || language === 'xml') prismLang = 'markup';
if (Prism.languages[prismLang]) {
try {
const html = Prism.highlight(text, Prism.languages[prismLang], prismLang);
return (
<code
className={`${className} whitespace-pre-wrap font-mono text-sm`}
dangerouslySetInnerHTML={{ __html: html }}
{...props}
// Avoid passing children when using dangerouslySetInnerHTML
children={undefined}
/>
);
} catch (e) {
console.error('Prism highlight error', e);
}
}
// Fallback to unhighlighted
return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm`} {...props}>{children}</code>;
},
// Unwrap <pre> for custom components (gallery etc.)
pre: ({ node, children, ...props }) => {
// Check the actual AST node type to see if it's our custom gallery
const firstChild = node?.children?.[0];
if (firstChild?.type === 'element' && firstChild?.tagName === 'code') {
const isGallery = Array.isArray(firstChild.properties?.className)
&& firstChild.properties?.className.includes('language-custom-gallery');
const isMermaid = Array.isArray(firstChild.properties?.className)
&& firstChild.properties?.className.includes('language-mermaid');
if (isGallery || isMermaid) {
return <>{children}</>;
}
// Normal code block
return <pre className={`${props.className || ''} whitespace-pre-wrap break-words overflow-x-auto p-4 rounded-lg bg-muted/50 border border-border/50 mt-4 mb-4`} {...props}>{children}</pre>;
}
// Fallback
return <pre {...props}>{children}</pre>;
},
}}
>
{finalContent}
</ReactMarkdown>
</div>
{lightboxOpen && mockMediaItem && (
<Suspense fallback={null}>
<SmartLightbox
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
mediaItem={mockMediaItem}
imageUrl={mockMediaItem.image_url}
imageTitle={mockMediaItem.title}
user={user}
isVideo={false}
// Dummy handlers for actions that aren't supported in this context
onPublish={async () => { }}
onNavigate={handleNavigate}
onOpenInWizard={() => { }}
currentIndex={currentImageIndex}
totalCount={allImages.length}
/>
</Suspense>
)}
</>
);
});
MarkdownRenderer.displayName = 'MarkdownRenderer';
export default MarkdownRenderer;

View File

@ -1,73 +1,73 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2 } from "lucide-react";
import { Link } from "react-router-dom";
const OrganizationsList = () => {
const { data: organizations, isLoading } = useQuery({
queryKey: ["organizations"],
queryFn: async () => {
const { data, error } = await supabase
.from("organizations")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
},
});
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-6 bg-muted rounded w-3/4 mb-2" />
<div className="h-4 bg-muted rounded w-1/2" />
</CardHeader>
</Card>
))}
</div>
);
}
if (!organizations || organizations.length === 0) {
return null;
}
return (
<div className="py-16 px-4">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4">
<span className="bg-gradient-primary bg-clip-text text-transparent">
Organizations
</span>
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Explore creative communities and their collections
</p>
</div>
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.map((org) => (
<Link key={org.id} to={`/org/${org.slug}`}>
<Card className="hover:shadow-lg transition-all duration-300 hover:scale-105 cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-primary/10">
<Building2 className="w-6 h-6 text-primary" />
</div>
</div>
<CardTitle className="text-xl">{org.name}</CardTitle>
<CardDescription>@{org.slug}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
};
export default OrganizationsList;
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2 } from "lucide-react";
import { Link } from "react-router-dom";
const OrganizationsList = () => {
const { data: organizations, isLoading } = useQuery({
queryKey: ["organizations"],
queryFn: async () => {
const { data, error } = await supabase
.from("organizations")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
},
});
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-6 bg-muted rounded w-3/4 mb-2" />
<div className="h-4 bg-muted rounded w-1/2" />
</CardHeader>
</Card>
))}
</div>
);
}
if (!organizations || organizations.length === 0) {
return null;
}
return (
<div className="py-16 px-4">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold mb-4">
<span className="bg-gradient-primary bg-clip-text text-transparent">
Organizations
</span>
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Explore creative communities and their collections
</p>
</div>
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.map((org) => (
<Link key={org.id} to={`/org/${org.slug}`}>
<Card className="hover:shadow-lg transition-all duration-300 hover:scale-105 cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-primary/10">
<Building2 className="w-6 h-6 text-primary" />
</div>
</div>
<CardTitle className="text-xl">{org.name}</CardTitle>
<CardDescription>@{org.slug}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
};
export default OrganizationsList;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,282 +1,282 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { Upload, RefreshCw, GitBranch, Bookmark } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
interface Collection {
id: string;
name: string;
slug: string;
}
interface PublishDialogProps {
isOpen: boolean;
onClose: () => void;
onPublish: (option: 'overwrite' | 'new' | 'version' | 'add-to-post', title?: string, description?: string, parentId?: string, collectionIds?: string[]) => void;
originalTitle: string;
originalImageId?: string;
isPublishing?: boolean;
editingPostId?: string;
}
export default function PublishDialog({
isOpen,
onClose,
onPublish,
originalTitle,
originalImageId,
isPublishing = false,
editingPostId
}: PublishDialogProps) {
const { user } = useAuth();
const [publishOption, setPublishOption] = useState<'overwrite' | 'new' | 'version' | 'add-to-post'>('new');
const [title, setTitle] = useState(originalTitle);
const [description, setDescription] = useState('');
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
// Load user's collections when dialog opens
useEffect(() => {
if (isOpen && user) {
loadCollections();
// Default to "add-to-post" if editing a post
if (editingPostId) {
setPublishOption('add-to-post');
} else {
setPublishOption('new');
}
}
}, [isOpen, user, editingPostId]);
const loadCollections = async () => {
if (!user) return;
setLoadingCollections(true);
try {
const { data, error } = await supabase
.from('collections')
.select('id, name, slug')
.eq('user_id', user.id)
.order('name');
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error loading collections:', error);
} finally {
setLoadingCollections(false);
}
};
const toggleCollection = (collectionId: string) => {
setSelectedCollections(prev =>
prev.includes(collectionId)
? prev.filter(id => id !== collectionId)
: [...prev, collectionId]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onPublish(
publishOption,
title.trim() || undefined,
description.trim() || undefined,
originalImageId,
selectedCollections.length > 0 ? selectedCollections : undefined
);
};
const handleClose = () => {
if (!isPublishing) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-[10000] flex items-center justify-center p-4">
<div className="bg-background rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-semibold text-foreground mb-4">
Publish Generated Image
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label className="text-sm font-medium text-foreground mb-3 block">
Publishing Option
</Label>
<RadioGroup
value={publishOption}
onValueChange={(value) => setPublishOption(value as 'overwrite' | 'new' | 'version' | 'add-to-post')}
className="space-y-3"
>
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="overwrite" id="overwrite" />
<div className="flex-1">
<Label htmlFor="overwrite" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
Overwrite Original
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Replace the original image with the generated version
</p>
</div>
</div>
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="new" id="new" />
<div className="flex-1">
<Label htmlFor="new" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Create New Post
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Save as a new image in your gallery
</p>
</div>
</div>
{originalImageId && (
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="version" id="version" />
<div className="flex-1">
<Label htmlFor="version" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Save as Version
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Create a new version linked to the original image
</p>
</div>
</div>
)}
</RadioGroup>
</div>
{(publishOption === 'new' || publishOption === 'version' || publishOption === 'add-to-post') && (
<div className="space-y-4 pt-2">
<div>
<Label htmlFor="title" className="text-sm font-medium text-foreground">
Title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a title..."
className="mt-1"
disabled={isPublishing}
/>
</div>
<div>
<Label htmlFor="description" className="text-sm font-medium text-foreground">
Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description for your generated image..."
className="mt-1 resize-none"
rows={3}
disabled={isPublishing}
/>
</div>
{/* Collections Selection */}
{collections.length > 0 && (
<div>
<Label className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Bookmark className="h-4 w-4" />
Add to Collections <span className="text-muted-foreground">(optional)</span>
</Label>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto border rounded-lg p-3">
{loadingCollections ? (
<div className="text-sm text-muted-foreground text-center py-2">
Loading collections...
</div>
) : (
collections.map((collection) => (
<div key={collection.id} className="flex items-center space-x-2">
<Checkbox
id={`collection-${collection.id}`}
checked={selectedCollections.includes(collection.id)}
onCheckedChange={() => toggleCollection(collection.id)}
disabled={isPublishing}
/>
<label
htmlFor={`collection-${collection.id}`}
className="text-sm cursor-pointer flex-1"
>
{collection.name}
</label>
</div>
))
)}
</div>
{selectedCollections.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
Will be added to {selectedCollections.length} collection{selectedCollections.length > 1 ? 's' : ''}
</p>
)}
</div>
)}
</div>
)}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
disabled={isPublishing}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isPublishing}
>
{isPublishing ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
Publishing...
</div>
) : (
<div className="flex items-center gap-2">
{publishOption === 'overwrite' && <RefreshCw className="h-4 w-4" />}
{publishOption === 'new' && <Upload className="h-4 w-4" />}
{publishOption === 'version' && <GitBranch className="h-4 w-4" />}
{publishOption === 'add-to-post' && <Bookmark className="h-4 w-4" />}
{publishOption === 'overwrite' ? 'Overwrite' :
publishOption === 'version' ? 'Save Version' :
publishOption === 'add-to-post' ? 'Add to Post' : 'Publish New'}
</div>
)}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { Upload, RefreshCw, GitBranch, Bookmark } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
interface Collection {
id: string;
name: string;
slug: string;
}
interface PublishDialogProps {
isOpen: boolean;
onClose: () => void;
onPublish: (option: 'overwrite' | 'new' | 'version' | 'add-to-post', title?: string, description?: string, parentId?: string, collectionIds?: string[]) => void;
originalTitle: string;
originalImageId?: string;
isPublishing?: boolean;
editingPostId?: string;
}
export default function PublishDialog({
isOpen,
onClose,
onPublish,
originalTitle,
originalImageId,
isPublishing = false,
editingPostId
}: PublishDialogProps) {
const { user } = useAuth();
const [publishOption, setPublishOption] = useState<'overwrite' | 'new' | 'version' | 'add-to-post'>('new');
const [title, setTitle] = useState(originalTitle);
const [description, setDescription] = useState('');
const [collections, setCollections] = useState<Collection[]>([]);
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
const [loadingCollections, setLoadingCollections] = useState(false);
// Load user's collections when dialog opens
useEffect(() => {
if (isOpen && user) {
loadCollections();
// Default to "add-to-post" if editing a post
if (editingPostId) {
setPublishOption('add-to-post');
} else {
setPublishOption('new');
}
}
}, [isOpen, user, editingPostId]);
const loadCollections = async () => {
if (!user) return;
setLoadingCollections(true);
try {
const { data, error } = await supabase
.from('collections')
.select('id, name, slug')
.eq('user_id', user.id)
.order('name');
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error('Error loading collections:', error);
} finally {
setLoadingCollections(false);
}
};
const toggleCollection = (collectionId: string) => {
setSelectedCollections(prev =>
prev.includes(collectionId)
? prev.filter(id => id !== collectionId)
: [...prev, collectionId]
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onPublish(
publishOption,
title.trim() || undefined,
description.trim() || undefined,
originalImageId,
selectedCollections.length > 0 ? selectedCollections : undefined
);
};
const handleClose = () => {
if (!isPublishing) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-[10000] flex items-center justify-center p-4">
<div className="bg-background rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-semibold text-foreground mb-4">
Publish Generated Image
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label className="text-sm font-medium text-foreground mb-3 block">
Publishing Option
</Label>
<RadioGroup
value={publishOption}
onValueChange={(value) => setPublishOption(value as 'overwrite' | 'new' | 'version' | 'add-to-post')}
className="space-y-3"
>
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="overwrite" id="overwrite" />
<div className="flex-1">
<Label htmlFor="overwrite" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
Overwrite Original
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Replace the original image with the generated version
</p>
</div>
</div>
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="new" id="new" />
<div className="flex-1">
<Label htmlFor="new" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Create New Post
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Save as a new image in your gallery
</p>
</div>
</div>
{originalImageId && (
<div className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<RadioGroupItem value="version" id="version" />
<div className="flex-1">
<Label htmlFor="version" className="font-medium cursor-pointer">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Save as Version
</div>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Create a new version linked to the original image
</p>
</div>
</div>
)}
</RadioGroup>
</div>
{(publishOption === 'new' || publishOption === 'version' || publishOption === 'add-to-post') && (
<div className="space-y-4 pt-2">
<div>
<Label htmlFor="title" className="text-sm font-medium text-foreground">
Title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a title..."
className="mt-1"
disabled={isPublishing}
/>
</div>
<div>
<Label htmlFor="description" className="text-sm font-medium text-foreground">
Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description for your generated image..."
className="mt-1 resize-none"
rows={3}
disabled={isPublishing}
/>
</div>
{/* Collections Selection */}
{collections.length > 0 && (
<div>
<Label className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Bookmark className="h-4 w-4" />
Add to Collections <span className="text-muted-foreground">(optional)</span>
</Label>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto border rounded-lg p-3">
{loadingCollections ? (
<div className="text-sm text-muted-foreground text-center py-2">
Loading collections...
</div>
) : (
collections.map((collection) => (
<div key={collection.id} className="flex items-center space-x-2">
<Checkbox
id={`collection-${collection.id}`}
checked={selectedCollections.includes(collection.id)}
onCheckedChange={() => toggleCollection(collection.id)}
disabled={isPublishing}
/>
<label
htmlFor={`collection-${collection.id}`}
className="text-sm cursor-pointer flex-1"
>
{collection.name}
</label>
</div>
))
)}
</div>
{selectedCollections.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
Will be added to {selectedCollections.length} collection{selectedCollections.length > 1 ? 's' : ''}
</p>
)}
</div>
)}
</div>
)}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
disabled={isPublishing}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isPublishing}
>
{isPublishing ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
Publishing...
</div>
) : (
<div className="flex items-center gap-2">
{publishOption === 'overwrite' && <RefreshCw className="h-4 w-4" />}
{publishOption === 'new' && <Upload className="h-4 w-4" />}
{publishOption === 'version' && <GitBranch className="h-4 w-4" />}
{publishOption === 'add-to-post' && <Bookmark className="h-4 w-4" />}
{publishOption === 'overwrite' ? 'Overwrite' :
publishOption === 'version' ? 'Save Version' :
publishOption === 'add-to-post' ? 'Add to Post' : 'Publish New'}
</div>
)}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@ -1,93 +1,93 @@
import React from 'react';
import { PromptTemplate } from '../types';
import { Save, Download, Upload } from 'lucide-react';
interface TemplateManagerProps {
prompts: PromptTemplate[];
currentPrompt: string;
onSelectPrompt: (prompt: string) => void;
onSavePrompt: (name: string, text: string) => void;
onImportPrompts: () => void;
onExportPrompts: () => void;
}
const TemplateManager: React.FC<TemplateManagerProps> = ({
prompts,
currentPrompt,
onSelectPrompt,
onSavePrompt,
onImportPrompts,
onExportPrompts,
}) => {
const handleSaveTemplate = () => {
if (!currentPrompt.trim()) return;
const name = prompt('Enter template name:');
if (name && name.trim()) {
onSavePrompt(name.trim(), currentPrompt);
}
};
return (
<div className="border border-slate-200/50 dark:border-white/30 rounded-xl p-4 bg-slate-50/30 dark:bg-slate-800/90">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left: Template Picker */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2">Templates</h4>
<div className="flex flex-wrap gap-1">
{prompts.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-200">No templates saved yet</span>
) : (
prompts.map((template) => (
<button
key={template.name}
type="button"
onClick={() => onSelectPrompt(template.text)}
className="text-xs px-2 py-1 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-700 dark:hover:bg-purple-600 text-purple-700 dark:text-white transition-colors duration-200"
title={`Load template: ${template.text.substring(0, 50)}...`}
>
{template.name}
</button>
))
)}
</div>
</div>
{/* Right: Template Management Icons */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2">Manage</h4>
<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={handleSaveTemplate}
disabled={!currentPrompt.trim()}
className="p-2 rounded bg-green-100 hover:bg-green-200 dark:bg-green-600 dark:hover:bg-green-500 text-green-700 dark:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save current prompt as template"
>
<Save size={16} />
</button>
<button
type="button"
onClick={onImportPrompts}
className="p-2 rounded bg-blue-100 hover:bg-blue-200 dark:bg-blue-600 dark:hover:bg-blue-500 text-blue-700 dark:text-white transition-colors duration-200"
title="Import templates from file"
>
<Upload size={16} />
</button>
<button
type="button"
onClick={onExportPrompts}
disabled={prompts.length === 0}
className="p-2 rounded bg-orange-100 hover:bg-orange-200 dark:bg-orange-600 dark:hover:bg-orange-500 text-orange-700 dark:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Export templates to file"
>
<Download size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default TemplateManager;
import React from 'react';
import { PromptTemplate } from '../types';
import { Save, Download, Upload } from 'lucide-react';
interface TemplateManagerProps {
prompts: PromptTemplate[];
currentPrompt: string;
onSelectPrompt: (prompt: string) => void;
onSavePrompt: (name: string, text: string) => void;
onImportPrompts: () => void;
onExportPrompts: () => void;
}
const TemplateManager: React.FC<TemplateManagerProps> = ({
prompts,
currentPrompt,
onSelectPrompt,
onSavePrompt,
onImportPrompts,
onExportPrompts,
}) => {
const handleSaveTemplate = () => {
if (!currentPrompt.trim()) return;
const name = prompt('Enter template name:');
if (name && name.trim()) {
onSavePrompt(name.trim(), currentPrompt);
}
};
return (
<div className="border border-slate-200/50 dark:border-white/30 rounded-xl p-4 bg-slate-50/30 dark:bg-slate-800/90">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left: Template Picker */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2">Templates</h4>
<div className="flex flex-wrap gap-1">
{prompts.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-200">No templates saved yet</span>
) : (
prompts.map((template) => (
<button
key={template.name}
type="button"
onClick={() => onSelectPrompt(template.text)}
className="text-xs px-2 py-1 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-700 dark:hover:bg-purple-600 text-purple-700 dark:text-white transition-colors duration-200"
title={`Load template: ${template.text.substring(0, 50)}...`}
>
{template.name}
</button>
))
)}
</div>
</div>
{/* Right: Template Management Icons */}
<div>
<h4 className="text-sm font-semibold text-slate-700 dark:text-white mb-2">Manage</h4>
<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={handleSaveTemplate}
disabled={!currentPrompt.trim()}
className="p-2 rounded bg-green-100 hover:bg-green-200 dark:bg-green-600 dark:hover:bg-green-500 text-green-700 dark:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save current prompt as template"
>
<Save size={16} />
</button>
<button
type="button"
onClick={onImportPrompts}
className="p-2 rounded bg-blue-100 hover:bg-blue-200 dark:bg-blue-600 dark:hover:bg-blue-500 text-blue-700 dark:text-white transition-colors duration-200"
title="Import templates from file"
>
<Upload size={16} />
</button>
<button
type="button"
onClick={onExportPrompts}
disabled={prompts.length === 0}
className="p-2 rounded bg-orange-100 hover:bg-orange-200 dark:bg-orange-600 dark:hover:bg-orange-500 text-orange-700 dark:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Export templates to file"
>
<Download size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
export default TemplateManager;

View File

@ -1,76 +1,76 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
if (theme === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = () => {
root.classList.remove("light", "dark");
root.classList.add(mediaQuery.matches ? "dark" : "light");
};
applyTheme();
mediaQuery.addEventListener("change", applyTheme);
return () => mediaQuery.removeEventListener("change", applyTheme);
}
root.classList.remove("light", "dark");
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
if (theme === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = () => {
root.classList.remove("light", "dark");
root.classList.add(mediaQuery.matches ? "dark" : "light");
};
applyTheme();
mediaQuery.addEventListener("change", applyTheme);
return () => mediaQuery.removeEventListener("change", applyTheme);
}
root.classList.remove("light", "dark");
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@ -1,39 +1,39 @@
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/components/ThemeProvider";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 w-9 px-0">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/components/ThemeProvider";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-9 w-9 px-0">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,339 +1,339 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Home, User, Upload, LogOut, LogIn, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity, ShoppingCart, Pencil, Settings, MessageSquare } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useWizardContext } from "@/hooks/useWizardContext";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "react-router-dom";
import { useProfiles } from "@/contexts/ProfilesContext";
import { useState, useRef, useEffect, lazy, Suspense } from "react";
import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n";
const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup })));
const TopNavigation = () => {
const { user, signOut, roles } = useAuth();
const { fetchProfile, profiles } = useProfiles();
const location = useLocation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const currentLang = getCurrentLang();
const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
// Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load
const [cartItemCount, setCartItemCount] = useState(0);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
import("@polymech/ecommerce").then(({ useCartStore }) => {
// Read initial value
setCartItemCount(useCartStore.getState().itemCount);
// Subscribe to changes
unsubscribe = useCartStore.subscribe((state) => {
setCartItemCount(state.itemCount);
});
}).catch(() => { /* ecommerce not available */ });
return () => unsubscribe?.();
}, []);
const authPath = '/auth';
useEffect(() => {
if (user?.id) {
fetchProfile(user.id);
}
}, [user?.id, fetchProfile]);
const userProfile = user ? profiles[user.id] : null;
const username = userProfile?.user_id || user?.id;
const isActive = (path: string) => location.pathname === path;
{/* Profile Grid Button - Direct to profile feed */ }
{
user && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/user/${username}`)}
className="h-8 w-8 p-0"
title={translate("My Profile")}
>
<Grid3x3 className="h-4 w-4" />
</Button>
)
}
// ...
<DropdownMenuItem asChild>
<Link to={`/user/${username}`} className="flex items-center">
<User className="mr-2 h-4 w-4" />
<T>Profile</T>
</Link>
</DropdownMenuItem>
const handleLanguageChange = (langCode: string) => {
setLanguage(langCode as any);
};
const handleSignOut = async () => {
await signOut();
};
const handleWizardOpen = () => {
setCreationWizardOpen(true);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
searchInputRef.current?.blur();
}
};
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setSearchQuery('');
searchInputRef.current?.blur();
}
};
return (
<header className="sticky top-0 z-50 w-full h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 landscape:hidden lg:landscape:block">
<div className="flex items-center justify-between px-2 h-full">
{/* Logo / Brand */}
<Link to="/" className="flex items-center space-x-2">
<img src="/iPhone.png" alt="Logo" className="h-8 w-auto object-contain" />
<span className="font-bold text-lg hidden sm:inline-block">PolyMech</span>
</Link>
{/* Search Bar - Center */}
<div className="flex-1 max-w-md mx-4 hidden sm:block">
<form onSubmit={handleSearchSubmit} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
type="search"
placeholder={translate("Search articles, pictures, files ...")}
className="pl-10 pr-4 h-9 w-full bg-muted/50 border-0 focus-visible:ring-1 focus-visible:ring-primary"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</form>
</div>
{/* Center Navigation - Desktop only, moved after search */}
<nav className="hidden lg:flex items-center space-x-6">
</nav>
{/* Right Side Actions */}
<div className="flex items-center space-x-2">
{/* Support Chat - public */}
<Button
variant={isActive('/support-chat') ? 'default' : 'ghost'}
size="sm"
onClick={() => navigate('/support-chat')}
className="h-8 w-8 p-0"
title={translate("Support Chat")}
>
<MessageSquare className="h-4 w-4" />
</Button>
{/* Mobile Search Button */}
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/search')}
className="h-8 w-8 p-0 sm:hidden"
title={translate("Search")}
>
<Search className="h-4 w-4" />
</Button>
{/* Magic Button - AI Image Generator */}
{user && (
<Button
variant="ghost"
size="sm"
onClick={handleWizardOpen}
className="h-8 w-8 p-0"
title={translate("AI Image Generator")}
>
<Wand2 className="h-4 w-4" />
</Button>
)}
{/* Profile Grid Button - Direct to profile feed */}
{user && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/user/${user.id}`)}
className="h-8 w-8 p-0"
title={translate("My Profile")}
>
<Grid3x3 className="h-4 w-4" />
</Button>
)}
{/* Cart Icon with Badge */}
{cartItemCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/cart')}
className="h-8 w-8 p-0 relative"
title={translate("Cart")}
>
<ShoppingCart className="h-4 w-4" />
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{cartItemCount > 9 ? '9+' : cartItemCount}
</span>
</Button>
)}
{/* Language Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title={translate("Language")}
>
<Globe className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{supportedLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onSelect={() => handleLanguageChange(lang.code)}
className={currentLang === lang.code ? 'bg-accent' : ''}
>
{lang.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ThemeToggle />
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-gradient-primary text-white">
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">User {user.id.slice(0, 8)}</p>
<p className="w-[200px] truncate text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}`} className="flex items-center">
<User className="mr-2 h-4 w-4" />
<T>Profile</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}/pages/myspace?edit=true`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<T>Edit Profile Page</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/profile" className="flex items-center">
<Settings className="mr-2 h-4 w-4" />
<T>Settings</T>
</Link>
</DropdownMenuItem>
{roles.includes("admin") && (
<>
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center">
<Shield className="mr-2 h-4 w-4" />
<T>Admin</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}/pages/home?edit=true`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>Edit Home Page</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/playground/i18n`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>I18n</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/playground/chat`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>Chat</T>
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>
<Link to="/?upload=true" className="flex items-center">
<Upload className="mr-2 h-4 w-4" />
<T>Upload</T>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="flex items-center">
<LogOut className="mr-2 h-4 w-4" />
<T>Sign out</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="sm" asChild>
<Link to={authPath} className="flex items-center">
<LogIn className="mr-2 h-4 w-4" />
<T>Sign in</T>
</Link>
</Button>
)}
</div>
</div>
{user && creationWizardOpen && (
<Suspense fallback={null}>
<CreationWizardPopup
isOpen={creationWizardOpen}
onClose={() => setCreationWizardOpen(false)}
preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
initialMode={creationWizardMode}
/>
</Suspense>
)}
</header>
);
};
export default TopNavigation;
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Home, User, Upload, LogOut, LogIn, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity, ShoppingCart, Pencil, Settings, MessageSquare } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useWizardContext } from "@/hooks/useWizardContext";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "react-router-dom";
import { useProfiles } from "@/contexts/ProfilesContext";
import { useState, useRef, useEffect, lazy, Suspense } from "react";
import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n";
const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup })));
const TopNavigation = () => {
const { user, signOut, roles } = useAuth();
const { fetchProfile, profiles } = useProfiles();
const location = useLocation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const currentLang = getCurrentLang();
const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
// Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load
const [cartItemCount, setCartItemCount] = useState(0);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
import("@polymech/ecommerce").then(({ useCartStore }) => {
// Read initial value
setCartItemCount(useCartStore.getState().itemCount);
// Subscribe to changes
unsubscribe = useCartStore.subscribe((state) => {
setCartItemCount(state.itemCount);
});
}).catch(() => { /* ecommerce not available */ });
return () => unsubscribe?.();
}, []);
const authPath = '/auth';
useEffect(() => {
if (user?.id) {
fetchProfile(user.id);
}
}, [user?.id, fetchProfile]);
const userProfile = user ? profiles[user.id] : null;
const username = userProfile?.user_id || user?.id;
const isActive = (path: string) => location.pathname === path;
{/* Profile Grid Button - Direct to profile feed */ }
{
user && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/user/${username}`)}
className="h-8 w-8 p-0"
title={translate("My Profile")}
>
<Grid3x3 className="h-4 w-4" />
</Button>
)
}
// ...
<DropdownMenuItem asChild>
<Link to={`/user/${username}`} className="flex items-center">
<User className="mr-2 h-4 w-4" />
<T>Profile</T>
</Link>
</DropdownMenuItem>
const handleLanguageChange = (langCode: string) => {
setLanguage(langCode as any);
};
const handleSignOut = async () => {
await signOut();
};
const handleWizardOpen = () => {
setCreationWizardOpen(true);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
searchInputRef.current?.blur();
}
};
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setSearchQuery('');
searchInputRef.current?.blur();
}
};
return (
<header className="sticky top-0 z-50 w-full h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 landscape:hidden lg:landscape:block">
<div className="flex items-center justify-between px-2 h-full">
{/* Logo / Brand */}
<Link to="/" className="flex items-center space-x-2">
<img src="/iPhone.png" alt="Logo" className="h-8 w-auto object-contain" />
<span className="font-bold text-lg hidden sm:inline-block">PolyMech</span>
</Link>
{/* Search Bar - Center */}
<div className="flex-1 max-w-md mx-4 hidden sm:block">
<form onSubmit={handleSearchSubmit} className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
type="search"
placeholder={translate("Search articles, pictures, files ...")}
className="pl-10 pr-4 h-9 w-full bg-muted/50 border-0 focus-visible:ring-1 focus-visible:ring-primary"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
/>
</form>
</div>
{/* Center Navigation - Desktop only, moved after search */}
<nav className="hidden lg:flex items-center space-x-6">
</nav>
{/* Right Side Actions */}
<div className="flex items-center space-x-2">
{/* Support Chat - public */}
<Button
variant={isActive('/support-chat') ? 'default' : 'ghost'}
size="sm"
onClick={() => navigate('/support-chat')}
className="h-8 w-8 p-0"
title={translate("Support Chat")}
>
<MessageSquare className="h-4 w-4" />
</Button>
{/* Mobile Search Button */}
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/search')}
className="h-8 w-8 p-0 sm:hidden"
title={translate("Search")}
>
<Search className="h-4 w-4" />
</Button>
{/* Magic Button - AI Image Generator */}
{user && (
<Button
variant="ghost"
size="sm"
onClick={handleWizardOpen}
className="h-8 w-8 p-0"
title={translate("AI Image Generator")}
>
<Wand2 className="h-4 w-4" />
</Button>
)}
{/* Profile Grid Button - Direct to profile feed */}
{user && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/user/${user.id}`)}
className="h-8 w-8 p-0"
title={translate("My Profile")}
>
<Grid3x3 className="h-4 w-4" />
</Button>
)}
{/* Cart Icon with Badge */}
{cartItemCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/cart')}
className="h-8 w-8 p-0 relative"
title={translate("Cart")}
>
<ShoppingCart className="h-4 w-4" />
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{cartItemCount > 9 ? '9+' : cartItemCount}
</span>
</Button>
)}
{/* Language Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
title={translate("Language")}
>
<Globe className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{supportedLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onSelect={() => handleLanguageChange(lang.code)}
className={currentLang === lang.code ? 'bg-accent' : ''}
>
{lang.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ThemeToggle />
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-gradient-primary text-white">
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">User {user.id.slice(0, 8)}</p>
<p className="w-[200px] truncate text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}`} className="flex items-center">
<User className="mr-2 h-4 w-4" />
<T>Profile</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}/pages/myspace?edit=true`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<T>Edit Profile Page</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/profile" className="flex items-center">
<Settings className="mr-2 h-4 w-4" />
<T>Settings</T>
</Link>
</DropdownMenuItem>
{roles.includes("admin") && (
<>
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center">
<Shield className="mr-2 h-4 w-4" />
<T>Admin</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/user/${user.id}/pages/home?edit=true`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>Edit Home Page</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/playground/i18n`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>I18n</T>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/playground/chat`} className="flex items-center">
<Home className="mr-2 h-4 w-4" />
<T>Chat</T>
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>
<Link to="/?upload=true" className="flex items-center">
<Upload className="mr-2 h-4 w-4" />
<T>Upload</T>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="flex items-center">
<LogOut className="mr-2 h-4 w-4" />
<T>Sign out</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="sm" asChild>
<Link to={authPath} className="flex items-center">
<LogIn className="mr-2 h-4 w-4" />
<T>Sign in</T>
</Link>
</Button>
)}
</div>
</div>
{user && creationWizardOpen && (
<Suspense fallback={null}>
<CreationWizardPopup
isOpen={creationWizardOpen}
onClose={() => setCreationWizardOpen(false)}
preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
initialMode={creationWizardMode}
/>
</Suspense>
)}
</header>
);
};
export default TopNavigation;

View File

@ -1,337 +1,337 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Check, Image as ImageIcon, Eye, EyeOff, Trash2 } from 'lucide-react';
import { fetchPictureById, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Version {
id: string;
title: string;
image_url: string;
is_selected: boolean;
created_at: string;
parent_id: string | null;
visible: boolean;
user_id: string; // Added for storage deletion path
}
interface VersionSelectorProps {
currentPictureId: string;
onVersionSelect: (selectedVersionId: string) => void;
}
const VersionSelector: React.FC<VersionSelectorProps> = ({
currentPictureId,
onVersionSelect
}) => {
const { user } = useAuth();
const [versions, setVersions] = useState<Version[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [toggling, setToggling] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<Version | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadVersions();
}, [currentPictureId]);
const loadVersions = async () => {
if (!user || !currentPictureId) return;
setLoading(true);
try {
// Get the current picture to determine if it's a parent or child
const currentPicture = await fetchPictureById(currentPictureId);
if (!currentPicture) throw new Error('Picture not found');
// Fetch all user pictures and walk the full tree (same as VersionMap)
const allPictures = await fetchUserPictures(currentPicture.user_id);
if (!allPictures || allPictures.length === 0) {
setVersions([]);
return;
}
const pictureMap = new Map(allPictures.map((p: any) => [p.id, p]));
// Walk up to find the true root
let trueRootId = currentPicture.id;
let current: any = pictureMap.get(currentPicture.id);
while (current?.parent_id && pictureMap.has(current.parent_id)) {
trueRootId = current.parent_id;
current = pictureMap.get(current.parent_id);
}
// Collect all descendants from root
const tree: any[] = [];
const visited = new Set<string>();
const collect = (nodeId: string) => {
if (visited.has(nodeId)) return;
const node = pictureMap.get(nodeId);
if (!node) return;
visited.add(nodeId);
tree.push(node);
allPictures.filter((p: any) => p.parent_id === nodeId).forEach((child: any) => collect(child.id));
};
collect(trueRootId);
setVersions(tree || []);
} catch (error) {
console.error('Error loading versions:', error);
toast.error(translate('Failed to load image versions'));
} finally {
setLoading(false);
}
};
const handleVersionSelect = async (versionId: string) => {
if (!user) return;
setUpdating(versionId);
try {
const targetVersion = versions.find(v => v.id === versionId);
if (!targetVersion) return;
// Toggle: if already selected, unselect; otherwise select
const newSelected = !targetVersion.is_selected;
await updatePicture(versionId, { is_selected: newSelected } as any);
// Update local state
setVersions(prevVersions =>
prevVersions.map(v => ({
...v,
is_selected: v.id === versionId ? newSelected : v.is_selected
}))
);
toast.success(translate(newSelected ? 'Version selected!' : 'Version unselected!'));
onVersionSelect(versionId);
} catch (error) {
console.error('Error toggling version selection:', error);
toast.error(translate('Failed to update version'));
} finally {
setUpdating(null);
}
};
const handleToggleVisibility = async (versionId: string, currentVisibility: boolean) => {
if (!user) return;
setToggling(versionId);
try {
await updatePicture(versionId, { visible: !currentVisibility } as any);
// Update local state
setVersions(prevVersions =>
prevVersions.map(v => ({
...v,
visible: v.id === versionId ? !currentVisibility : v.visible
}))
);
toast.success(translate(!currentVisibility ? 'Version made visible successfully!' : 'Version hidden successfully!'));
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update visibility'));
} finally {
setToggling(null);
}
};
const handleDeleteClick = (version: Version) => {
setVersionToDelete(version);
setShowDeleteDialog(true);
};
const confirmDelete = async () => {
if (!versionToDelete || !user) return;
setIsDeleting(true);
try {
// 1. Find all descendants to delete (cascade)
const allUserPictures = await fetchUserPictures(user.id);
const findDescendants = (parentId: string): any[] => {
const descendants: any[] = [];
const children = allUserPictures.filter((p: any) => p.parent_id === parentId);
children.forEach((child: any) => {
descendants.push(child);
descendants.push(...findDescendants(child.id));
});
return descendants;
};
const descendantsToDelete = findDescendants(versionToDelete.id);
const allToDelete = [versionToDelete, ...descendantsToDelete];
const idsToDelete = allToDelete.map(v => v.id);
// 2. Batch delete via API (handles storage + db)
await deletePictures(idsToDelete);
// 3. Update local state
const deletedIds = new Set(idsToDelete);
setVersions(prev => prev.filter(v => !deletedIds.has(v.id)));
const totalDeleted = allToDelete.length;
toast.success(translate(`Deleted ${totalDeleted > 1 ? `${totalDeleted} versions` : 'version'} successfully`));
} catch (error) {
console.error('Error deleting version:', error);
toast.error(translate('Failed to delete version'));
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setVersionToDelete(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-4">
<div className="text-muted-foreground"><T>Loading versions...</T></div>
</div>
);
}
if (versions.length <= 1) {
return (
<div className="text-center p-4">
<p className="text-muted-foreground"><T>No other versions available for this image.</T></p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<ImageIcon className="h-5 w-5" />
<h3 className="font-semibold"><T>Image Versions</T></h3>
<Badge variant="secondary">{versions.length}</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{versions.map((version) => (
<Card
key={version.id}
className={`cursor-pointer transition-all hover:scale-105 ${version.is_selected ? 'ring-2 ring-primary' : ''
}`}
>
<CardContent className="p-2">
<div className="aspect-square relative mb-2 overflow-hidden rounded-md">
<img
src={version.image_url}
alt={version.title}
className="w-full h-full object-cover"
/>
{version.is_selected && (
<div className="absolute top-2 right-2 bg-primary text-primary-foreground rounded-full p-1">
<Check className="h-3 w-3" />
</div>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-medium truncate">{version.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(version.created_at).toLocaleDateString()}
</p>
<div className="flex items-center gap-1">
{version.parent_id === null && (
<Badge variant="outline" className="text-xs"><T>Original</T></Badge>
)}
{!version.visible && (
<Badge variant="secondary" className="text-xs"><T>Hidden</T></Badge>
)}
</div>
</div>
<div className="flex gap-1 mt-2">
<Button
size="sm"
className="flex-1"
variant={version.is_selected ? "default" : "outline"}
onClick={() => handleVersionSelect(version.id)}
disabled={updating === version.id}
>
<T>{updating === version.id ? 'Updating...' :
version.is_selected ? 'Unselect' : 'Select'}</T>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleVisibility(version.id, version.visible)}
disabled={toggling === version.id}
className="px-2"
>
{toggling === version.id ? (
<div className="animate-spin rounded-full h-3 w-3 border border-current border-t-transparent" />
) : version.visible ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="ghost"
className="px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteClick(version)}
disabled={isDeleting || version.id === currentPictureId} // Disable deleting the currently ACTIVE one? Or just handle correctly?
title={translate("Delete version")}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Delete Version</T></AlertDialogTitle>
<AlertDialogDescription>
<T>Are you sure you want to delete this version?</T> "{versionToDelete?.title}"
<br /><br />
<span className="text-destructive font-semibold">
<T>This action cannot be undone.</T>
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? <T>Deleting...</T> : <T>Delete</T>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default VersionSelector;
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Check, Image as ImageIcon, Eye, EyeOff, Trash2 } from 'lucide-react';
import { fetchPictureById, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Version {
id: string;
title: string;
image_url: string;
is_selected: boolean;
created_at: string;
parent_id: string | null;
visible: boolean;
user_id: string; // Added for storage deletion path
}
interface VersionSelectorProps {
currentPictureId: string;
onVersionSelect: (selectedVersionId: string) => void;
}
const VersionSelector: React.FC<VersionSelectorProps> = ({
currentPictureId,
onVersionSelect
}) => {
const { user } = useAuth();
const [versions, setVersions] = useState<Version[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [toggling, setToggling] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<Version | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadVersions();
}, [currentPictureId]);
const loadVersions = async () => {
if (!user || !currentPictureId) return;
setLoading(true);
try {
// Get the current picture to determine if it's a parent or child
const currentPicture = await fetchPictureById(currentPictureId);
if (!currentPicture) throw new Error('Picture not found');
// Fetch all user pictures and walk the full tree (same as VersionMap)
const allPictures = await fetchUserPictures(currentPicture.user_id);
if (!allPictures || allPictures.length === 0) {
setVersions([]);
return;
}
const pictureMap = new Map(allPictures.map((p: any) => [p.id, p]));
// Walk up to find the true root
let trueRootId = currentPicture.id;
let current: any = pictureMap.get(currentPicture.id);
while (current?.parent_id && pictureMap.has(current.parent_id)) {
trueRootId = current.parent_id;
current = pictureMap.get(current.parent_id);
}
// Collect all descendants from root
const tree: any[] = [];
const visited = new Set<string>();
const collect = (nodeId: string) => {
if (visited.has(nodeId)) return;
const node = pictureMap.get(nodeId);
if (!node) return;
visited.add(nodeId);
tree.push(node);
allPictures.filter((p: any) => p.parent_id === nodeId).forEach((child: any) => collect(child.id));
};
collect(trueRootId);
setVersions(tree || []);
} catch (error) {
console.error('Error loading versions:', error);
toast.error(translate('Failed to load image versions'));
} finally {
setLoading(false);
}
};
const handleVersionSelect = async (versionId: string) => {
if (!user) return;
setUpdating(versionId);
try {
const targetVersion = versions.find(v => v.id === versionId);
if (!targetVersion) return;
// Toggle: if already selected, unselect; otherwise select
const newSelected = !targetVersion.is_selected;
await updatePicture(versionId, { is_selected: newSelected } as any);
// Update local state
setVersions(prevVersions =>
prevVersions.map(v => ({
...v,
is_selected: v.id === versionId ? newSelected : v.is_selected
}))
);
toast.success(translate(newSelected ? 'Version selected!' : 'Version unselected!'));
onVersionSelect(versionId);
} catch (error) {
console.error('Error toggling version selection:', error);
toast.error(translate('Failed to update version'));
} finally {
setUpdating(null);
}
};
const handleToggleVisibility = async (versionId: string, currentVisibility: boolean) => {
if (!user) return;
setToggling(versionId);
try {
await updatePicture(versionId, { visible: !currentVisibility } as any);
// Update local state
setVersions(prevVersions =>
prevVersions.map(v => ({
...v,
visible: v.id === versionId ? !currentVisibility : v.visible
}))
);
toast.success(translate(!currentVisibility ? 'Version made visible successfully!' : 'Version hidden successfully!'));
} catch (error) {
console.error('Error toggling visibility:', error);
toast.error(translate('Failed to update visibility'));
} finally {
setToggling(null);
}
};
const handleDeleteClick = (version: Version) => {
setVersionToDelete(version);
setShowDeleteDialog(true);
};
const confirmDelete = async () => {
if (!versionToDelete || !user) return;
setIsDeleting(true);
try {
// 1. Find all descendants to delete (cascade)
const allUserPictures = await fetchUserPictures(user.id);
const findDescendants = (parentId: string): any[] => {
const descendants: any[] = [];
const children = allUserPictures.filter((p: any) => p.parent_id === parentId);
children.forEach((child: any) => {
descendants.push(child);
descendants.push(...findDescendants(child.id));
});
return descendants;
};
const descendantsToDelete = findDescendants(versionToDelete.id);
const allToDelete = [versionToDelete, ...descendantsToDelete];
const idsToDelete = allToDelete.map(v => v.id);
// 2. Batch delete via API (handles storage + db)
await deletePictures(idsToDelete);
// 3. Update local state
const deletedIds = new Set(idsToDelete);
setVersions(prev => prev.filter(v => !deletedIds.has(v.id)));
const totalDeleted = allToDelete.length;
toast.success(translate(`Deleted ${totalDeleted > 1 ? `${totalDeleted} versions` : 'version'} successfully`));
} catch (error) {
console.error('Error deleting version:', error);
toast.error(translate('Failed to delete version'));
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setVersionToDelete(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-4">
<div className="text-muted-foreground"><T>Loading versions...</T></div>
</div>
);
}
if (versions.length <= 1) {
return (
<div className="text-center p-4">
<p className="text-muted-foreground"><T>No other versions available for this image.</T></p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<ImageIcon className="h-5 w-5" />
<h3 className="font-semibold"><T>Image Versions</T></h3>
<Badge variant="secondary">{versions.length}</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{versions.map((version) => (
<Card
key={version.id}
className={`cursor-pointer transition-all hover:scale-105 ${version.is_selected ? 'ring-2 ring-primary' : ''
}`}
>
<CardContent className="p-2">
<div className="aspect-square relative mb-2 overflow-hidden rounded-md">
<img
src={version.image_url}
alt={version.title}
className="w-full h-full object-cover"
/>
{version.is_selected && (
<div className="absolute top-2 right-2 bg-primary text-primary-foreground rounded-full p-1">
<Check className="h-3 w-3" />
</div>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-medium truncate">{version.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(version.created_at).toLocaleDateString()}
</p>
<div className="flex items-center gap-1">
{version.parent_id === null && (
<Badge variant="outline" className="text-xs"><T>Original</T></Badge>
)}
{!version.visible && (
<Badge variant="secondary" className="text-xs"><T>Hidden</T></Badge>
)}
</div>
</div>
<div className="flex gap-1 mt-2">
<Button
size="sm"
className="flex-1"
variant={version.is_selected ? "default" : "outline"}
onClick={() => handleVersionSelect(version.id)}
disabled={updating === version.id}
>
<T>{updating === version.id ? 'Updating...' :
version.is_selected ? 'Unselect' : 'Select'}</T>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleVisibility(version.id, version.visible)}
disabled={toggling === version.id}
className="px-2"
>
{toggling === version.id ? (
<div className="animate-spin rounded-full h-3 w-3 border border-current border-t-transparent" />
) : version.visible ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="ghost"
className="px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteClick(version)}
disabled={isDeleting || version.id === currentPictureId} // Disable deleting the currently ACTIVE one? Or just handle correctly?
title={translate("Delete version")}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Delete Version</T></AlertDialogTitle>
<AlertDialogDescription>
<T>Are you sure you want to delete this version?</T> "{versionToDelete?.title}"
<br /><br />
<span className="text-destructive font-semibold">
<T>This action cannot be undone.</T>
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? <T>Deleting...</T> : <T>Delete</T>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default VersionSelector;

View File

@ -72,4 +72,4 @@ const LogsPage = () => {
);
};
export default LogsPage;
export default LogsPage;

View File

@ -1,15 +1,15 @@
import { useDndContext, useTreeApi } from "../context";
export function Cursor() {
const tree = useTreeApi();
const state = useDndContext();
const cursor = state.cursor;
if (!cursor || cursor.type !== "line") return null;
const indent = tree.indent;
const top =
tree.rowHeight * cursor.index +
(tree.props.padding ?? tree.props.paddingTop ?? 0);
const left = indent * cursor.level;
const Cursor = tree.renderCursor;
return <Cursor {...{ top, left, indent }} />;
}
import { useDndContext, useTreeApi } from "../context";
export function Cursor() {
const tree = useTreeApi();
const state = useDndContext();
const cursor = state.cursor;
if (!cursor || cursor.type !== "line") return null;
const indent = tree.indent;
const top =
tree.rowHeight * cursor.index +
(tree.props.padding ?? tree.props.paddingTop ?? 0);
const left = indent * cursor.level;
const Cursor = tree.renderCursor;
return <Cursor {...{ top, left, indent }} />;
}

View File

@ -1,239 +1,239 @@
import { FixedSizeList } from "react-window";
import { useDataUpdates, useTreeApi } from "../context";
import { focusNextElement, focusPrevElement } from "../utils";
import { ListOuterElement } from "./list-outer-element";
import { ListInnerElement } from "./list-inner-element";
import { RowContainer } from "./row-container";
let focusSearchTerm = "";
let timeoutId: any = null;
/**
* All these keyboard shortcuts seem like they should be configurable.
* Each operation should be a given a name and separated from
* the event handler. Future clean up welcome.
*/
export function DefaultContainer() {
useDataUpdates();
const tree = useTreeApi();
return (
<div
role="tree"
style={{
height: tree.height,
width: tree.width,
minHeight: 0,
minWidth: 0,
}}
onContextMenu={tree.props.onContextMenu}
onClick={tree.props.onClick}
tabIndex={0}
onFocus={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
tree.onFocus();
}
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
tree.onBlur();
}
}}
onKeyDown={(e) => {
if (tree.isEditing) {
return;
}
if (e.key === "Backspace") {
if (!tree.props.onDelete) return;
const ids = Array.from(tree.selectedIds);
if (ids.length > 1) {
let nextFocus = tree.mostRecentNode;
while (nextFocus && nextFocus.isSelected) {
nextFocus = nextFocus.nextSibling;
}
if (!nextFocus) nextFocus = tree.lastNode;
tree.focus(nextFocus, { scroll: false });
tree.delete(Array.from(ids));
} else {
const node = tree.focusedNode;
if (node) {
const sib = node.nextSibling;
const parent = node.parent;
tree.focus(sib || parent, { scroll: false });
tree.delete(node);
}
}
return;
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
focusNextElement(e.currentTarget);
return;
}
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
focusPrevElement(e.currentTarget);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
const next = tree.nextNode;
if (e.metaKey) {
tree.select(tree.focusedNode);
tree.activate(tree.focusedNode);
return;
} else if (!e.shiftKey || tree.props.disableMultiSelection) {
tree.focus(next);
return;
} else {
if (!next) return;
const current = tree.focusedNode;
if (!current) {
tree.focus(tree.firstNode);
} else if (current.isSelected) {
tree.selectContiguous(next);
} else {
tree.selectMulti(next);
}
return;
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
const prev = tree.prevNode;
if (!e.shiftKey || tree.props.disableMultiSelection) {
tree.focus(prev);
return;
} else {
if (!prev) return;
const current = tree.focusedNode;
if (!current) {
tree.focus(tree.lastNode); // ?
} else if (current.isSelected) {
tree.selectContiguous(prev);
} else {
tree.selectMulti(prev);
}
return;
}
}
if (e.key === "ArrowRight") {
const node = tree.focusedNode;
if (!node) return;
if (node.isInternal && node.isOpen) {
tree.focus(tree.nextNode);
} else if (node.isInternal) tree.open(node.id);
return;
}
if (e.key === "ArrowLeft") {
const node = tree.focusedNode;
if (!node || node.isRoot) return;
if (node.isInternal && node.isOpen) tree.close(node.id);
else if (!node.parent?.isRoot) {
tree.focus(node.parent);
}
return;
}
if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
e.preventDefault();
tree.selectAll();
return;
}
if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
tree.createLeaf();
return;
}
if (e.key === "A" && !e.metaKey) {
if (!tree.props.onCreate) return;
tree.createInternal();
return;
}
if (e.key === "Home") {
// add shift keys
e.preventDefault();
tree.focus(tree.firstNode);
return;
}
if (e.key === "End") {
// add shift keys
e.preventDefault();
tree.focus(tree.lastNode);
return;
}
if (e.key === "Enter") {
const node = tree.focusedNode;
if (!node) return;
if (!node.isEditable || !tree.props.onRename) return;
setTimeout(() => {
if (node) tree.edit(node);
});
return;
}
if (e.key === " ") {
e.preventDefault();
const node = tree.focusedNode;
if (!node) return;
if (node.isLeaf) {
node.select();
node.activate();
} else {
node.toggle();
}
return;
}
if (e.key === "*") {
const node = tree.focusedNode;
if (!node) return;
tree.openSiblings(node);
return;
}
if (e.key === "PageUp") {
e.preventDefault();
tree.pageUp();
return;
}
if (e.key === "PageDown") {
e.preventDefault();
tree.pageDown();
}
// If they type a sequence of characters
// collect them. Reset them after a timeout.
// Use it to search the tree for a node, then focus it.
// Clean this up a bit later
clearTimeout(timeoutId);
focusSearchTerm += e.key;
timeoutId = setTimeout(() => {
focusSearchTerm = "";
}, 600);
const node = tree.visibleNodes.find((n) => {
// @ts-ignore
const name = n.data.name;
if (typeof name === "string") {
return name.toLowerCase().startsWith(focusSearchTerm);
} else return false;
});
if (node) tree.focus(node.id);
}}
>
{/* @ts-ignore */}
<FixedSizeList
className={tree.props.className}
outerRef={tree.listEl}
itemCount={tree.visibleNodes.length}
height={tree.height}
width={tree.width}
itemSize={tree.rowHeight}
overscanCount={tree.overscanCount}
itemKey={(index) => tree.visibleNodes[index]?.id || index}
outerElementType={ListOuterElement}
innerElementType={ListInnerElement}
onScroll={tree.props.onScroll}
onItemsRendered={tree.onItemsRendered.bind(tree)}
ref={tree.list}
>
{RowContainer}
</FixedSizeList>
</div>
);
}
import { FixedSizeList } from "react-window";
import { useDataUpdates, useTreeApi } from "../context";
import { focusNextElement, focusPrevElement } from "../utils";
import { ListOuterElement } from "./list-outer-element";
import { ListInnerElement } from "./list-inner-element";
import { RowContainer } from "./row-container";
let focusSearchTerm = "";
let timeoutId: any = null;
/**
* All these keyboard shortcuts seem like they should be configurable.
* Each operation should be a given a name and separated from
* the event handler. Future clean up welcome.
*/
export function DefaultContainer() {
useDataUpdates();
const tree = useTreeApi();
return (
<div
role="tree"
style={{
height: tree.height,
width: tree.width,
minHeight: 0,
minWidth: 0,
}}
onContextMenu={tree.props.onContextMenu}
onClick={tree.props.onClick}
tabIndex={0}
onFocus={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
tree.onFocus();
}
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
tree.onBlur();
}
}}
onKeyDown={(e) => {
if (tree.isEditing) {
return;
}
if (e.key === "Backspace") {
if (!tree.props.onDelete) return;
const ids = Array.from(tree.selectedIds);
if (ids.length > 1) {
let nextFocus = tree.mostRecentNode;
while (nextFocus && nextFocus.isSelected) {
nextFocus = nextFocus.nextSibling;
}
if (!nextFocus) nextFocus = tree.lastNode;
tree.focus(nextFocus, { scroll: false });
tree.delete(Array.from(ids));
} else {
const node = tree.focusedNode;
if (node) {
const sib = node.nextSibling;
const parent = node.parent;
tree.focus(sib || parent, { scroll: false });
tree.delete(node);
}
}
return;
}
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
focusNextElement(e.currentTarget);
return;
}
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
focusPrevElement(e.currentTarget);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
const next = tree.nextNode;
if (e.metaKey) {
tree.select(tree.focusedNode);
tree.activate(tree.focusedNode);
return;
} else if (!e.shiftKey || tree.props.disableMultiSelection) {
tree.focus(next);
return;
} else {
if (!next) return;
const current = tree.focusedNode;
if (!current) {
tree.focus(tree.firstNode);
} else if (current.isSelected) {
tree.selectContiguous(next);
} else {
tree.selectMulti(next);
}
return;
}
}
if (e.key === "ArrowUp") {
e.preventDefault();
const prev = tree.prevNode;
if (!e.shiftKey || tree.props.disableMultiSelection) {
tree.focus(prev);
return;
} else {
if (!prev) return;
const current = tree.focusedNode;
if (!current) {
tree.focus(tree.lastNode); // ?
} else if (current.isSelected) {
tree.selectContiguous(prev);
} else {
tree.selectMulti(prev);
}
return;
}
}
if (e.key === "ArrowRight") {
const node = tree.focusedNode;
if (!node) return;
if (node.isInternal && node.isOpen) {
tree.focus(tree.nextNode);
} else if (node.isInternal) tree.open(node.id);
return;
}
if (e.key === "ArrowLeft") {
const node = tree.focusedNode;
if (!node || node.isRoot) return;
if (node.isInternal && node.isOpen) tree.close(node.id);
else if (!node.parent?.isRoot) {
tree.focus(node.parent);
}
return;
}
if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
e.preventDefault();
tree.selectAll();
return;
}
if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
tree.createLeaf();
return;
}
if (e.key === "A" && !e.metaKey) {
if (!tree.props.onCreate) return;
tree.createInternal();
return;
}
if (e.key === "Home") {
// add shift keys
e.preventDefault();
tree.focus(tree.firstNode);
return;
}
if (e.key === "End") {
// add shift keys
e.preventDefault();
tree.focus(tree.lastNode);
return;
}
if (e.key === "Enter") {
const node = tree.focusedNode;
if (!node) return;
if (!node.isEditable || !tree.props.onRename) return;
setTimeout(() => {
if (node) tree.edit(node);
});
return;
}
if (e.key === " ") {
e.preventDefault();
const node = tree.focusedNode;
if (!node) return;
if (node.isLeaf) {
node.select();
node.activate();
} else {
node.toggle();
}
return;
}
if (e.key === "*") {
const node = tree.focusedNode;
if (!node) return;
tree.openSiblings(node);
return;
}
if (e.key === "PageUp") {
e.preventDefault();
tree.pageUp();
return;
}
if (e.key === "PageDown") {
e.preventDefault();
tree.pageDown();
}
// If they type a sequence of characters
// collect them. Reset them after a timeout.
// Use it to search the tree for a node, then focus it.
// Clean this up a bit later
clearTimeout(timeoutId);
focusSearchTerm += e.key;
timeoutId = setTimeout(() => {
focusSearchTerm = "";
}, 600);
const node = tree.visibleNodes.find((n) => {
// @ts-ignore
const name = n.data.name;
if (typeof name === "string") {
return name.toLowerCase().startsWith(focusSearchTerm);
} else return false;
});
if (node) tree.focus(node.id);
}}
>
{/* @ts-ignore */}
<FixedSizeList
className={tree.props.className}
outerRef={tree.listEl}
itemCount={tree.visibleNodes.length}
height={tree.height}
width={tree.width}
itemSize={tree.rowHeight}
overscanCount={tree.overscanCount}
itemKey={(index) => tree.visibleNodes[index]?.id || index}
outerElementType={ListOuterElement}
innerElementType={ListInnerElement}
onScroll={tree.props.onScroll}
onItemsRendered={tree.onItemsRendered.bind(tree)}
ref={tree.list}
>
{RowContainer}
</FixedSizeList>
</div>
);
}

View File

@ -1,42 +1,42 @@
import React, { CSSProperties } from "react";
import { CursorProps } from "../types/renderers";
const placeholderStyle = {
display: "flex",
alignItems: "center",
zIndex: 1,
};
const lineStyle = {
flex: 1,
height: "2px",
background: "#4B91E2",
borderRadius: "1px",
};
const circleStyle = {
width: "4px",
height: "4px",
boxShadow: "0 0 0 3px #4B91E2",
borderRadius: "50%",
};
export const DefaultCursor = React.memo(function DefaultCursor({
top,
left,
indent,
}: CursorProps) {
const style: CSSProperties = {
position: "absolute",
pointerEvents: "none",
top: top - 2 + "px",
left: left + "px",
right: indent + "px",
};
return (
<div style={{ ...placeholderStyle, ...style }}>
<div style={{ ...circleStyle }}></div>
<div style={{ ...lineStyle }}></div>
</div>
);
});
import React, { CSSProperties } from "react";
import { CursorProps } from "../types/renderers";
const placeholderStyle = {
display: "flex",
alignItems: "center",
zIndex: 1,
};
const lineStyle = {
flex: 1,
height: "2px",
background: "#4B91E2",
borderRadius: "1px",
};
const circleStyle = {
width: "4px",
height: "4px",
boxShadow: "0 0 0 3px #4B91E2",
borderRadius: "50%",
};
export const DefaultCursor = React.memo(function DefaultCursor({
top,
left,
indent,
}: CursorProps) {
const style: CSSProperties = {
position: "absolute",
pointerEvents: "none",
top: top - 2 + "px",
left: left + "px",
right: indent + "px",
};
return (
<div style={{ ...placeholderStyle, ...style }}>
<div style={{ ...circleStyle }}></div>
<div style={{ ...lineStyle }}></div>
</div>
);
});

View File

@ -1,92 +1,92 @@
import React, { CSSProperties, memo } from "react";
import { XYCoord } from "react-dnd";
import { useTreeApi } from "../context";
import { DragPreviewProps } from "../types/renderers";
import { IdObj } from "../types/utils";
const layerStyles: CSSProperties = {
position: "fixed",
pointerEvents: "none",
zIndex: 100,
left: 0,
top: 0,
width: "100%",
height: "100%",
};
const getStyle = (offset: XYCoord | null) => {
if (!offset) return { display: "none" };
const { x, y } = offset;
return { transform: `translate(${x}px, ${y}px)` };
};
const getCountStyle = (offset: XYCoord | null) => {
if (!offset) return { display: "none" };
const { x, y } = offset;
return { transform: `translate(${x + 10}px, ${y + 10}px)` };
};
export function DefaultDragPreview({
offset,
mouse,
id,
dragIds,
isDragging,
}: DragPreviewProps) {
return (
<Overlay isDragging={isDragging}>
<Position offset={offset}>
<PreviewNode id={id} dragIds={dragIds} />
</Position>
<Count mouse={mouse} count={dragIds.length} />
</Overlay>
);
}
const Overlay = memo(function Overlay(props: {
children: JSX.Element[];
isDragging: boolean;
}) {
if (!props.isDragging) return null;
return <div style={layerStyles}>{props.children}</div>;
});
function Position(props: { children: JSX.Element; offset: XYCoord | null }) {
return (
<div className="row preview" style={getStyle(props.offset)}>
{props.children}
</div>
);
}
function Count(props: { count: number; mouse: XYCoord | null }) {
const { count, mouse } = props;
if (count > 1)
return (
<div className="selected-count" style={getCountStyle(mouse)}>
{count}
</div>
);
else return null;
}
const PreviewNode = memo(function PreviewNode<T>(props: {
id: string | null;
dragIds: string[];
}) {
const tree = useTreeApi<T>();
const node = tree.get(props.id);
if (!node) return null;
return (
<tree.renderNode
preview
node={node}
style={{
paddingLeft: node.level * tree.indent,
opacity: 0.2,
background: "transparent",
}}
tree={tree}
/>
);
});
import React, { CSSProperties, memo } from "react";
import { XYCoord } from "react-dnd";
import { useTreeApi } from "../context";
import { DragPreviewProps } from "../types/renderers";
import { IdObj } from "../types/utils";
const layerStyles: CSSProperties = {
position: "fixed",
pointerEvents: "none",
zIndex: 100,
left: 0,
top: 0,
width: "100%",
height: "100%",
};
const getStyle = (offset: XYCoord | null) => {
if (!offset) return { display: "none" };
const { x, y } = offset;
return { transform: `translate(${x}px, ${y}px)` };
};
const getCountStyle = (offset: XYCoord | null) => {
if (!offset) return { display: "none" };
const { x, y } = offset;
return { transform: `translate(${x + 10}px, ${y + 10}px)` };
};
export function DefaultDragPreview({
offset,
mouse,
id,
dragIds,
isDragging,
}: DragPreviewProps) {
return (
<Overlay isDragging={isDragging}>
<Position offset={offset}>
<PreviewNode id={id} dragIds={dragIds} />
</Position>
<Count mouse={mouse} count={dragIds.length} />
</Overlay>
);
}
const Overlay = memo(function Overlay(props: {
children: JSX.Element[];
isDragging: boolean;
}) {
if (!props.isDragging) return null;
return <div style={layerStyles}>{props.children}</div>;
});
function Position(props: { children: JSX.Element; offset: XYCoord | null }) {
return (
<div className="row preview" style={getStyle(props.offset)}>
{props.children}
</div>
);
}
function Count(props: { count: number; mouse: XYCoord | null }) {
const { count, mouse } = props;
if (count > 1)
return (
<div className="selected-count" style={getCountStyle(mouse)}>
{count}
</div>
);
else return null;
}
const PreviewNode = memo(function PreviewNode<T>(props: {
id: string | null;
dragIds: string[];
}) {
const tree = useTreeApi<T>();
const node = tree.get(props.id);
if (!node) return null;
return (
<tree.renderNode
preview
node={node}
style={{
paddingLeft: node.level * tree.indent,
opacity: 0.2,
background: "transparent",
}}
tree={tree}
/>
);
});

View File

@ -1,50 +1,50 @@
import React, { useEffect, useRef } from "react";
import { NodeRendererProps } from "../types/renderers";
import { IdObj } from "../types/utils";
export function DefaultNode<T>(props: NodeRendererProps<T>) {
return (
<div ref={props.dragHandle} style={props.style}>
<span
onClick={(e) => {
e.stopPropagation();
props.node.toggle();
}}
>
{props.node.isLeaf ? "🌳" : props.node.isOpen ? "🗁" : "🗀"}
</span>{" "}
{props.node.isEditing ? <Edit {...props} /> : <Show {...props} />}
</div>
);
}
function Show<T>(props: NodeRendererProps<T>) {
return (
<>
{/* @ts-ignore */}
<span>{props.node.data.name}</span>
</>
);
}
function Edit<T>({ node }: NodeRendererProps<T>) {
const input = useRef<any>();
useEffect(() => {
input.current?.focus();
input.current?.select();
}, []);
return (
<input
ref={input}
// @ts-ignore
defaultValue={node.data.name}
onBlur={() => node.reset()}
onKeyDown={(e) => {
if (e.key === "Escape") node.reset();
if (e.key === "Enter") node.submit(input.current?.value || "");
}}
></input>
);
}
import React, { useEffect, useRef } from "react";
import { NodeRendererProps } from "../types/renderers";
import { IdObj } from "../types/utils";
export function DefaultNode<T>(props: NodeRendererProps<T>) {
return (
<div ref={props.dragHandle} style={props.style}>
<span
onClick={(e) => {
e.stopPropagation();
props.node.toggle();
}}
>
{props.node.isLeaf ? "🌳" : props.node.isOpen ? "🗁" : "🗀"}
</span>{" "}
{props.node.isEditing ? <Edit {...props} /> : <Show {...props} />}
</div>
);
}
function Show<T>(props: NodeRendererProps<T>) {
return (
<>
{/* @ts-ignore */}
<span>{props.node.data.name}</span>
</>
);
}
function Edit<T>({ node }: NodeRendererProps<T>) {
const input = useRef<any>();
useEffect(() => {
input.current?.focus();
input.current?.select();
}, []);
return (
<input
ref={input}
// @ts-ignore
defaultValue={node.data.name}
onBlur={() => node.reset()}
onKeyDown={(e) => {
if (e.key === "Escape") node.reset();
if (e.key === "Enter") node.submit(input.current?.value || "");
}}
></input>
);
}

View File

@ -1,21 +1,21 @@
import React from "react";
import { RowRendererProps } from "../types/renderers";
import { IdObj } from "../types/utils";
export function DefaultRow<T>({
node,
attrs,
innerRef,
children,
}: RowRendererProps<T>) {
return (
<div
{...attrs}
ref={innerRef}
onFocus={(e) => e.stopPropagation()}
onClick={node.handleClick}
>
{children}
</div>
);
}
import React from "react";
import { RowRendererProps } from "../types/renderers";
import { IdObj } from "../types/utils";
export function DefaultRow<T>({
node,
attrs,
innerRef,
children,
}: RowRendererProps<T>) {
return (
<div
{...attrs}
ref={innerRef}
onFocus={(e) => e.stopPropagation()}
onClick={node.handleClick}
>
{children}
</div>
);
}

View File

@ -1,26 +1,26 @@
import { useDragLayer } from "react-dnd";
import { useDndContext, useTreeApi } from "../context";
import { DefaultDragPreview } from "./default-drag-preview";
export function DragPreviewContainer() {
const tree = useTreeApi();
const { offset, mouse, item, isDragging } = useDragLayer((m) => {
return {
offset: m.getSourceClientOffset(),
mouse: m.getClientOffset(),
item: m.getItem(),
isDragging: m.isDragging(),
};
});
const DragPreview = tree.props.renderDragPreview || DefaultDragPreview;
return (
<DragPreview
offset={offset}
mouse={mouse}
id={item?.id || null}
dragIds={item?.dragIds || []}
isDragging={isDragging}
/>
);
}
import { useDragLayer } from "react-dnd";
import { useDndContext, useTreeApi } from "../context";
import { DefaultDragPreview } from "./default-drag-preview";
export function DragPreviewContainer() {
const tree = useTreeApi();
const { offset, mouse, item, isDragging } = useDragLayer((m) => {
return {
offset: m.getSourceClientOffset(),
mouse: m.getClientOffset(),
item: m.getItem(),
isDragging: m.isDragging(),
};
});
const DragPreview = tree.props.renderDragPreview || DefaultDragPreview;
return (
<DragPreview
offset={offset}
mouse={mouse}
id={item?.id || null}
dragIds={item?.dragIds || []}
isDragging={isDragging}
/>
);
}

View File

@ -1,22 +1,22 @@
import React from "react";
import { forwardRef } from "react";
import { useTreeApi } from "../context";
export const ListInnerElement = forwardRef<any, any>(function InnerElement(
{ style, ...rest },
ref
) {
const tree = useTreeApi();
const paddingTop = tree.props.padding ?? tree.props.paddingTop ?? 0;
const paddingBottom = tree.props.padding ?? tree.props.paddingBottom ?? 0;
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style.height) + paddingTop + paddingBottom}px`,
}}
{...rest}
/>
);
});
import React from "react";
import { forwardRef } from "react";
import { useTreeApi } from "../context";
export const ListInnerElement = forwardRef<any, any>(function InnerElement(
{ style, ...rest },
ref
) {
const tree = useTreeApi();
const paddingTop = tree.props.padding ?? tree.props.paddingTop ?? 0;
const paddingBottom = tree.props.padding ?? tree.props.paddingBottom ?? 0;
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style.height) + paddingTop + paddingBottom}px`,
}}
{...rest}
/>
);
});

View File

@ -1,42 +1,42 @@
import { forwardRef } from "react";
import { useTreeApi } from "../context";
import { treeBlur } from "../state/focus-slice";
import { Cursor } from "./cursor";
export const ListOuterElement = forwardRef(function Outer(
props: React.HTMLProps<HTMLDivElement>,
ref
) {
const { children, ...rest } = props;
const tree = useTreeApi();
return (
<div
// @ts-ignore
ref={ref}
{...rest}
onClick={(e) => {
if (e.currentTarget === e.target) tree.deselectAll();
}}
>
<DropContainer />
{children}
</div>
);
});
const DropContainer = () => {
const tree = useTreeApi();
return (
<div
style={{
height: tree.visibleNodes.length * tree.rowHeight,
width: "100%",
position: "absolute",
left: "0",
right: "0",
}}
>
<Cursor />
</div>
);
};
import { forwardRef } from "react";
import { useTreeApi } from "../context";
import { treeBlur } from "../state/focus-slice";
import { Cursor } from "./cursor";
export const ListOuterElement = forwardRef(function Outer(
props: React.HTMLProps<HTMLDivElement>,
ref
) {
const { children, ...rest } = props;
const tree = useTreeApi();
return (
<div
// @ts-ignore
ref={ref}
{...rest}
onClick={(e) => {
if (e.currentTarget === e.target) tree.deselectAll();
}}
>
<DropContainer />
{children}
</div>
);
});
const DropContainer = () => {
const tree = useTreeApi();
return (
<div
style={{
height: tree.visibleNodes.length * tree.rowHeight,
width: "100%",
position: "absolute",
left: "0",
right: "0",
}}
>
<Cursor />
</div>
);
};

View File

@ -1,7 +1,7 @@
import { ReactElement } from "react";
import { useOuterDrop } from "../dnd/outer-drop-hook";
export function OuterDrop(props: { children: ReactElement }) {
useOuterDrop();
return props.children;
}
import { ReactElement } from "react";
import { useOuterDrop } from "../dnd/outer-drop-hook";
export function OuterDrop(props: { children: ReactElement }) {
useOuterDrop();
return props.children;
}

View File

@ -1,98 +1,98 @@
import {
ReactNode,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { useSyncExternalStore } from "use-sync-external-store/shim";
import { FixedSizeList } from "react-window";
import {
DataUpdatesContext,
DndContext,
NodesContext,
TreeApiContext,
} from "../context";
import { TreeApi } from "../interfaces/tree-api";
import { initialState } from "../state/initial";
import { Actions, rootReducer, RootState } from "../state/root-reducer";
import { HTML5Backend } from "react-dnd-html5-backend";
import { DndProvider } from "react-dnd";
import { TreeProps } from "../types/tree-props";
import { createStore, Store } from "redux";
import { actions as visibility } from "../state/open-slice";
type Props<T> = {
treeProps: TreeProps<T>;
imperativeHandle: React.Ref<TreeApi<T> | undefined>;
children: ReactNode;
};
const SERVER_STATE = initialState();
export function TreeProvider<T>({
treeProps,
imperativeHandle,
children,
}: Props<T>) {
const list = useRef<FixedSizeList | null>(null);
const listEl = useRef<HTMLDivElement | null>(null);
const store = useRef<Store<RootState, Actions>>(
// @ts-ignore
createStore(rootReducer, initialState(treeProps))
);
const state = useSyncExternalStore<RootState>(
store.current.subscribe,
store.current.getState,
() => SERVER_STATE
);
/* The tree api object is stable. */
const api = useMemo(() => {
return new TreeApi<T>(store.current, treeProps, list, listEl);
}, []);
/* Make sure the tree instance stays in sync */
const updateCount = useRef(0);
useMemo(() => {
updateCount.current += 1;
api.update(treeProps);
}, [...Object.values(treeProps), state.nodes.open]);
/* Expose the tree api */
useImperativeHandle(imperativeHandle, () => api);
/* Change selection based on props */
useEffect(() => {
if (api.props.selection) {
api.select(api.props.selection, { focus: false });
} else {
api.deselectAll();
}
}, [api.props.selection]);
/* Clear visability for filtered nodes */
useEffect(() => {
if (!api.props.searchTerm) {
store.current.dispatch(visibility.clear(true));
}
}, [api.props.searchTerm]);
return (
<TreeApiContext.Provider value={api}>
<DataUpdatesContext.Provider value={updateCount.current}>
<NodesContext.Provider value={state.nodes}>
<DndContext.Provider value={state.dnd}>
<DndProvider
backend={HTML5Backend}
options={{ rootElement: api.props.dndRootElement || undefined }}
{...(treeProps.dndManager && { manager: treeProps.dndManager })}
>
{children}
</DndProvider>
</DndContext.Provider>
</NodesContext.Provider>
</DataUpdatesContext.Provider>
</TreeApiContext.Provider>
);
}
import {
ReactNode,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { useSyncExternalStore } from "use-sync-external-store/shim";
import { FixedSizeList } from "react-window";
import {
DataUpdatesContext,
DndContext,
NodesContext,
TreeApiContext,
} from "../context";
import { TreeApi } from "../interfaces/tree-api";
import { initialState } from "../state/initial";
import { Actions, rootReducer, RootState } from "../state/root-reducer";
import { HTML5Backend } from "react-dnd-html5-backend";
import { DndProvider } from "react-dnd";
import { TreeProps } from "../types/tree-props";
import { createStore, Store } from "redux";
import { actions as visibility } from "../state/open-slice";
type Props<T> = {
treeProps: TreeProps<T>;
imperativeHandle: React.Ref<TreeApi<T> | undefined>;
children: ReactNode;
};
const SERVER_STATE = initialState();
export function TreeProvider<T>({
treeProps,
imperativeHandle,
children,
}: Props<T>) {
const list = useRef<FixedSizeList | null>(null);
const listEl = useRef<HTMLDivElement | null>(null);
const store = useRef<Store<RootState, Actions>>(
// @ts-ignore
createStore(rootReducer, initialState(treeProps))
);
const state = useSyncExternalStore<RootState>(
store.current.subscribe,
store.current.getState,
() => SERVER_STATE
);
/* The tree api object is stable. */
const api = useMemo(() => {
return new TreeApi<T>(store.current, treeProps, list, listEl);
}, []);
/* Make sure the tree instance stays in sync */
const updateCount = useRef(0);
useMemo(() => {
updateCount.current += 1;
api.update(treeProps);
}, [...Object.values(treeProps), state.nodes.open]);
/* Expose the tree api */
useImperativeHandle(imperativeHandle, () => api);
/* Change selection based on props */
useEffect(() => {
if (api.props.selection) {
api.select(api.props.selection, { focus: false });
} else {
api.deselectAll();
}
}, [api.props.selection]);
/* Clear visability for filtered nodes */
useEffect(() => {
if (!api.props.searchTerm) {
store.current.dispatch(visibility.clear(true));
}
}, [api.props.searchTerm]);
return (
<TreeApiContext.Provider value={api}>
<DataUpdatesContext.Provider value={updateCount.current}>
<NodesContext.Provider value={state.nodes}>
<DndContext.Provider value={state.dnd}>
<DndProvider
backend={HTML5Backend}
options={{ rootElement: api.props.dndRootElement || undefined }}
{...(treeProps.dndManager && { manager: treeProps.dndManager })}
>
{children}
</DndProvider>
</DndContext.Provider>
</NodesContext.Provider>
</DataUpdatesContext.Provider>
</TreeApiContext.Provider>
);
}

View File

@ -1,83 +1,83 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useDataUpdates, useNodesContext, useTreeApi } from "../context";
import { useDragHook } from "../dnd/drag-hook";
import { useDropHook } from "../dnd/drop-hook";
import { useFreshNode } from "../hooks/use-fresh-node";
type Props = {
style: React.CSSProperties;
index: number;
};
export const RowContainer = React.memo(function RowContainer<T>({
index,
style,
}: Props) {
/* When will the <Row> will re-render.
*
* The row component is memo'd so it will only render
* when a new instance of the NodeApi class is passed
* to it.
*
* The TreeApi instance is stable. It does not
* change when the internal state changes.
*
* The TreeApi has all the references to the nodes.
* We need to clone the nodes when their state
* changes. The node class contains no state itself,
* It always checks the tree for state. The tree's
* state will always be up to date.
*/
useDataUpdates(); // Re-render when tree props or visability changes
const _ = useNodesContext(); // So that we re-render appropriately
const tree = useTreeApi<T>(); // Tree already has the fresh state
const node = useFreshNode<T>(index);
const el = useRef<HTMLDivElement | null>(null);
const dragRef = useDragHook<T>(node);
const dropRef = useDropHook(el, node);
const innerRef = useCallback(
(n: any) => {
el.current = n;
dropRef(n);
},
[dropRef]
);
const indent = tree.indent * node.level;
const nodeStyle = useMemo(() => ({ paddingLeft: indent }), [indent]);
const rowStyle = useMemo(
() => ({
...style,
top:
parseFloat(style.top as string) +
(tree.props.padding ?? tree.props.paddingTop ?? 0),
}),
[style, tree.props.padding, tree.props.paddingTop]
);
const rowAttrs: React.HTMLAttributes<any> = {
role: "treeitem",
"aria-level": node.level + 1,
"aria-selected": node.isSelected,
"aria-expanded": node.isOpen,
style: rowStyle,
tabIndex: -1,
className: tree.props.rowClassName,
};
useEffect(() => {
if (!node.isEditing && node.isFocused) {
el.current?.focus({ preventScroll: true });
}
}, [node.isEditing, node.isFocused, el.current]);
const Node = tree.renderNode;
const Row = tree.renderRow;
return (
<Row node={node} innerRef={innerRef} attrs={rowAttrs}>
<Node node={node} tree={tree} style={nodeStyle} dragHandle={dragRef} />
</Row>
);
});
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useDataUpdates, useNodesContext, useTreeApi } from "../context";
import { useDragHook } from "../dnd/drag-hook";
import { useDropHook } from "../dnd/drop-hook";
import { useFreshNode } from "../hooks/use-fresh-node";
type Props = {
style: React.CSSProperties;
index: number;
};
export const RowContainer = React.memo(function RowContainer<T>({
index,
style,
}: Props) {
/* When will the <Row> will re-render.
*
* The row component is memo'd so it will only render
* when a new instance of the NodeApi class is passed
* to it.
*
* The TreeApi instance is stable. It does not
* change when the internal state changes.
*
* The TreeApi has all the references to the nodes.
* We need to clone the nodes when their state
* changes. The node class contains no state itself,
* It always checks the tree for state. The tree's
* state will always be up to date.
*/
useDataUpdates(); // Re-render when tree props or visability changes
const _ = useNodesContext(); // So that we re-render appropriately
const tree = useTreeApi<T>(); // Tree already has the fresh state
const node = useFreshNode<T>(index);
const el = useRef<HTMLDivElement | null>(null);
const dragRef = useDragHook<T>(node);
const dropRef = useDropHook(el, node);
const innerRef = useCallback(
(n: any) => {
el.current = n;
dropRef(n);
},
[dropRef]
);
const indent = tree.indent * node.level;
const nodeStyle = useMemo(() => ({ paddingLeft: indent }), [indent]);
const rowStyle = useMemo(
() => ({
...style,
top:
parseFloat(style.top as string) +
(tree.props.padding ?? tree.props.paddingTop ?? 0),
}),
[style, tree.props.padding, tree.props.paddingTop]
);
const rowAttrs: React.HTMLAttributes<any> = {
role: "treeitem",
"aria-level": node.level + 1,
"aria-selected": node.isSelected,
"aria-expanded": node.isOpen,
style: rowStyle,
tabIndex: -1,
className: tree.props.rowClassName,
};
useEffect(() => {
if (!node.isEditing && node.isFocused) {
el.current?.focus({ preventScroll: true });
}
}, [node.isEditing, node.isFocused, el.current]);
const Node = tree.renderNode;
const Row = tree.renderRow;
return (
<Row node={node} innerRef={innerRef} attrs={rowAttrs}>
<Node node={node} tree={tree} style={nodeStyle} dragHandle={dragRef} />
</Row>
);
});

View File

@ -1,13 +1,13 @@
import React from "react";
import { useTreeApi } from "../context";
import { DefaultContainer } from "./default-container";
export function TreeContainer() {
const tree = useTreeApi();
const Container = tree.props.renderContainer || DefaultContainer;
return (
<>
<Container />
</>
);
}
import React from "react";
import { useTreeApi } from "../context";
import { DefaultContainer } from "./default-container";
export function TreeContainer() {
const tree = useTreeApi();
const Container = tree.props.renderContainer || DefaultContainer;
return (
<>
<Container />
</>
);
}

View File

@ -1,28 +1,28 @@
import { forwardRef } from "react";
import { TreeProvider } from "./provider";
import { TreeApi } from "../interfaces/tree-api";
import { OuterDrop } from "./outer-drop";
import { TreeContainer } from "./tree-container";
import { DragPreviewContainer } from "./drag-preview-container";
import { TreeProps } from "../types/tree-props";
import { IdObj } from "../types/utils";
import { useValidatedProps } from "../hooks/use-validated-props";
function TreeComponent<T>(
props: TreeProps<T>,
ref: React.Ref<TreeApi<T> | undefined>
) {
const treeProps = useValidatedProps(props);
return (
<TreeProvider treeProps={treeProps} imperativeHandle={ref}>
<OuterDrop>
<TreeContainer />
</OuterDrop>
<DragPreviewContainer />
</TreeProvider>
);
}
export const Tree = forwardRef(TreeComponent) as <T>(
props: TreeProps<T> & { ref?: React.ForwardedRef<TreeApi<T> | undefined> }
) => ReturnType<typeof TreeComponent>;
import { forwardRef } from "react";
import { TreeProvider } from "./provider";
import { TreeApi } from "../interfaces/tree-api";
import { OuterDrop } from "./outer-drop";
import { TreeContainer } from "./tree-container";
import { DragPreviewContainer } from "./drag-preview-container";
import { TreeProps } from "../types/tree-props";
import { IdObj } from "../types/utils";
import { useValidatedProps } from "../hooks/use-validated-props";
function TreeComponent<T>(
props: TreeProps<T>,
ref: React.Ref<TreeApi<T> | undefined>
) {
const treeProps = useValidatedProps(props);
return (
<TreeProvider treeProps={treeProps} imperativeHandle={ref}>
<OuterDrop>
<TreeContainer />
</OuterDrop>
<DragPreviewContainer />
</TreeProvider>
);
}
export const Tree = forwardRef(TreeComponent) as <T>(
props: TreeProps<T> & { ref?: React.ForwardedRef<TreeApi<T> | undefined> }
) => ReturnType<typeof TreeComponent>;

View File

@ -1,36 +1,36 @@
import React, { createContext, useContext, useMemo } from "react";
import { TreeApi } from "./interfaces/tree-api";
import { RootState } from "./state/root-reducer";
import { IdObj } from "./types/utils";
export const TreeApiContext = createContext<TreeApi<any> | null>(null);
export function useTreeApi<T>() {
const value = useContext<TreeApi<T> | null>(
TreeApiContext as unknown as React.Context<TreeApi<T> | null>
);
if (value === null) throw new Error("No Tree Api Provided");
return value;
}
export const NodesContext = createContext<RootState["nodes"] | null>(null);
export function useNodesContext() {
const value = useContext(NodesContext);
if (value === null) throw new Error("Provide a NodesContext");
return value;
}
export const DndContext = createContext<RootState["dnd"] | null>(null);
export function useDndContext() {
const value = useContext(DndContext);
if (value === null) throw new Error("Provide a DnDContext");
return value;
}
export const DataUpdatesContext = createContext<number>(0);
export function useDataUpdates() {
useContext(DataUpdatesContext);
}
import React, { createContext, useContext, useMemo } from "react";
import { TreeApi } from "./interfaces/tree-api";
import { RootState } from "./state/root-reducer";
import { IdObj } from "./types/utils";
export const TreeApiContext = createContext<TreeApi<any> | null>(null);
export function useTreeApi<T>() {
const value = useContext<TreeApi<T> | null>(
TreeApiContext as unknown as React.Context<TreeApi<T> | null>
);
if (value === null) throw new Error("No Tree Api Provided");
return value;
}
export const NodesContext = createContext<RootState["nodes"] | null>(null);
export function useNodesContext() {
const value = useContext(NodesContext);
if (value === null) throw new Error("Provide a NodesContext");
return value;
}
export const DndContext = createContext<RootState["dnd"] | null>(null);
export function useDndContext() {
const value = useContext(DndContext);
if (value === null) throw new Error("Provide a DnDContext");
return value;
}
export const DataUpdatesContext = createContext<number>(0);
export function useDataUpdates() {
useContext(DataUpdatesContext);
}

View File

@ -1,9 +1,9 @@
import { NodeApi } from "../interfaces/node-api";
import { IdObj } from "../types/utils";
export const createIndex = <T>(nodes: NodeApi<T>[]) => {
return nodes.reduce<{ [id: string]: number }>((map, node, index) => {
map[node.id] = index;
return map;
}, {});
};
import { NodeApi } from "../interfaces/node-api";
import { IdObj } from "../types/utils";
export const createIndex = <T>(nodes: NodeApi<T>[]) => {
return nodes.reduce<{ [id: string]: number }>((map, node, index) => {
map[node.id] = index;
return map;
}, {});
};

View File

@ -1,67 +1,67 @@
import { NodeApi } from "../interfaces/node-api";
import { TreeApi } from "../interfaces/tree-api";
import { IdObj } from "../types/utils";
export function createList<T>(tree: TreeApi<T>) {
if (tree.isFiltered) {
return flattenAndFilterTree(tree.root, tree.isMatch.bind(tree));
} else {
return flattenTree(tree.root);
}
}
function flattenTree<T>(root: NodeApi<T>): NodeApi<T>[] {
const list: NodeApi<T>[] = [];
function collect(node: NodeApi<T>) {
if (node.level >= 0) {
list.push(node);
}
if (node.isOpen) {
node.children?.forEach(collect);
}
}
collect(root);
list.forEach(assignRowIndex);
return list;
}
function flattenAndFilterTree<T>(
root: NodeApi<T>,
isMatch: (n: NodeApi<T>) => boolean
): NodeApi<T>[] {
const matches: Record<string, boolean> = {};
const list: NodeApi<T>[] = [];
function markMatch(node: NodeApi<T>) {
const yes = !node.isRoot && isMatch(node);
if (yes) {
matches[node.id] = true;
let parent = node.parent;
while (parent) {
matches[parent.id] = true;
parent = parent.parent;
}
}
if (node.children) {
for (let child of node.children) markMatch(child);
}
}
function collect(node: NodeApi<T>) {
if (node.level >= 0 && matches[node.id]) {
list.push(node);
}
if (node.isOpen) {
node.children?.forEach(collect);
}
}
markMatch(root);
collect(root);
list.forEach(assignRowIndex);
return list;
}
function assignRowIndex(node: NodeApi<any>, index: number) {
node.rowIndex = index;
}
import { NodeApi } from "../interfaces/node-api";
import { TreeApi } from "../interfaces/tree-api";
import { IdObj } from "../types/utils";
export function createList<T>(tree: TreeApi<T>) {
if (tree.isFiltered) {
return flattenAndFilterTree(tree.root, tree.isMatch.bind(tree));
} else {
return flattenTree(tree.root);
}
}
function flattenTree<T>(root: NodeApi<T>): NodeApi<T>[] {
const list: NodeApi<T>[] = [];
function collect(node: NodeApi<T>) {
if (node.level >= 0) {
list.push(node);
}
if (node.isOpen) {
node.children?.forEach(collect);
}
}
collect(root);
list.forEach(assignRowIndex);
return list;
}
function flattenAndFilterTree<T>(
root: NodeApi<T>,
isMatch: (n: NodeApi<T>) => boolean
): NodeApi<T>[] {
const matches: Record<string, boolean> = {};
const list: NodeApi<T>[] = [];
function markMatch(node: NodeApi<T>) {
const yes = !node.isRoot && isMatch(node);
if (yes) {
matches[node.id] = true;
let parent = node.parent;
while (parent) {
matches[parent.id] = true;
parent = parent.parent;
}
}
if (node.children) {
for (let child of node.children) markMatch(child);
}
}
function collect(node: NodeApi<T>) {
if (node.level >= 0 && matches[node.id]) {
list.push(node);
}
if (node.isOpen) {
node.children?.forEach(collect);
}
}
markMatch(root);
collect(root);
list.forEach(assignRowIndex);
return list;
}
function assignRowIndex(node: NodeApi<any>, index: number) {
node.rowIndex = index;
}

View File

@ -1,52 +1,52 @@
import { IdObj } from "../types/utils";
import { NodeApi } from "../interfaces/node-api";
import { TreeApi } from "../interfaces/tree-api";
export const ROOT_ID = "__REACT_ARBORIST_INTERNAL_ROOT__";
export function createRoot<T>(tree: TreeApi<T>): NodeApi<T> {
function visitSelfAndChildren(
data: T,
level: number,
parent: NodeApi<T> | null
) {
const id = tree.accessId(data);
const node = new NodeApi<T>({
tree,
data,
level,
parent,
id,
children: null,
isDraggable: tree.isDraggable(data),
rowIndex: null,
});
const children = tree.accessChildren(data);
if (children) {
node.children = children.map((child: T) =>
visitSelfAndChildren(child, level + 1, node)
);
}
return node;
}
const root = new NodeApi<T>({
tree,
id: ROOT_ID,
// @ts-ignore
data: { id: ROOT_ID },
level: -1,
parent: null,
children: null,
isDraggable: true,
rowIndex: null,
});
const data: readonly T[] = tree.props.data ?? [];
root.children = data.map((child) => {
return visitSelfAndChildren(child, 0, root);
});
return root;
}
import { IdObj } from "../types/utils";
import { NodeApi } from "../interfaces/node-api";
import { TreeApi } from "../interfaces/tree-api";
export const ROOT_ID = "__REACT_ARBORIST_INTERNAL_ROOT__";
export function createRoot<T>(tree: TreeApi<T>): NodeApi<T> {
function visitSelfAndChildren(
data: T,
level: number,
parent: NodeApi<T> | null
) {
const id = tree.accessId(data);
const node = new NodeApi<T>({
tree,
data,
level,
parent,
id,
children: null,
isDraggable: tree.isDraggable(data),
rowIndex: null,
});
const children = tree.accessChildren(data);
if (children) {
node.children = children.map((child: T) =>
visitSelfAndChildren(child, level + 1, node)
);
}
return node;
}
const root = new NodeApi<T>({
tree,
id: ROOT_ID,
// @ts-ignore
data: { id: ROOT_ID },
level: -1,
parent: null,
children: null,
isDraggable: true,
rowIndex: null,
});
const data: readonly T[] = tree.props.data ?? [];
root.children = data.map((child) => {
return visitSelfAndChildren(child, 0, root);
});
return root;
}

View File

@ -1,37 +1,37 @@
// A function that turns a string of text into a tree
// Each line is a node
// The number of spaces at the beginning indicate the level
export function makeTree(string: string) {
const root = { id: "ROOT", name: "ROOT", isOpen: true };
let prevNode = root;
let prevLevel = -1;
let id = 1;
string.split("\n").forEach((line) => {
const name = line.trimStart();
const level = line.length - name.length;
const diff = level - prevLevel;
const node = { id: (id++).toString(), name, isOpen: false };
if (diff === 1) {
// First child
//@ts-ignore
node.parent = prevNode;
//@ts-ignore
prevNode.children = [node];
} else {
// Find the parent and go up
//@ts-ignore
let parent = prevNode.parent;
for (let i = diff; i < 0; i++) {
parent = parent.parent;
}
//@ts-ignore
node.parent = parent;
parent.children.push(node);
}
prevNode = node;
prevLevel = level;
});
return root;
}
// A function that turns a string of text into a tree
// Each line is a node
// The number of spaces at the beginning indicate the level
export function makeTree(string: string) {
const root = { id: "ROOT", name: "ROOT", isOpen: true };
let prevNode = root;
let prevLevel = -1;
let id = 1;
string.split("\n").forEach((line) => {
const name = line.trimStart();
const level = line.length - name.length;
const diff = level - prevLevel;
const node = { id: (id++).toString(), name, isOpen: false };
if (diff === 1) {
// First child
//@ts-ignore
node.parent = prevNode;
//@ts-ignore
prevNode.children = [node];
} else {
// Find the parent and go up
//@ts-ignore
let parent = prevNode.parent;
for (let i = diff; i < 0; i++) {
parent = parent.parent;
}
//@ts-ignore
node.parent = parent;
parent.children.push(node);
}
prevNode = node;
prevLevel = level;
});
return root;
}

View File

@ -1,103 +1,103 @@
type SimpleData = { id: string; name: string; children?: SimpleData[] };
export class SimpleTree<T extends SimpleData> {
root: SimpleNode<T>;
constructor(data: T[]) {
this.root = createRoot<T>(data);
}
get data() {
return this.root.children?.map((node) => node.data) ?? [];
}
create(args: { parentId: string | null; index: number; data: T }) {
const parent = args.parentId ? this.find(args.parentId) : this.root;
if (!parent) return null;
parent.addChild(args.data, args.index);
}
move(args: { id: string; parentId: string | null; index: number }) {
const src = this.find(args.id);
const parent = args.parentId ? this.find(args.parentId) : this.root;
if (!src || !parent) return;
parent.addChild(src.data, args.index);
src.drop();
}
update(args: { id: string; changes: Partial<T> }) {
const node = this.find(args.id);
if (node) node.update(args.changes);
}
drop(args: { id: string }) {
const node = this.find(args.id);
if (node) node.drop();
}
find(id: string, node: SimpleNode<T> = this.root): SimpleNode<T> | null {
if (!node) return null;
if (node.id === id) return node as SimpleNode<T>;
if (node.children) {
for (let child of node.children) {
const found = this.find(id, child);
if (found) return found;
}
return null;
}
return null;
}
}
function createRoot<T extends SimpleData>(data: T[]) {
const root = new SimpleNode<T>({ id: "ROOT" } as T, null);
root.children = data.map((d) => createNode(d as T, root));
return root;
}
function createNode<T extends SimpleData>(data: T, parent: SimpleNode<T>) {
const node = new SimpleNode<T>(data, parent);
if (data.children)
node.children = data.children.map((d) => createNode<T>(d as T, node));
return node;
}
class SimpleNode<T extends SimpleData> {
id: string;
children?: SimpleNode<T>[];
constructor(public data: T, public parent: SimpleNode<T> | null) {
this.id = data.id;
}
hasParent(): this is this & { parent: SimpleNode<T> } {
return !!this.parent;
}
get childIndex(): number {
return this.hasParent() ? this.parent.children!.indexOf(this) : -1;
}
addChild(data: T, index: number) {
const node = createNode(data, this);
this.children = this.children ?? [];
this.children.splice(index, 0, node);
this.data.children = this.data.children ?? [];
this.data.children.splice(index, 0, data);
}
removeChild(index: number) {
this.children?.splice(index, 1);
this.data.children?.splice(index, 1);
}
update(changes: Partial<T>) {
if (this.hasParent()) {
const i = this.childIndex;
this.parent.addChild({ ...this.data, ...changes }, i);
this.drop();
}
}
drop() {
if (this.hasParent()) this.parent.removeChild(this.childIndex);
}
}
type SimpleData = { id: string; name: string; children?: SimpleData[] };
export class SimpleTree<T extends SimpleData> {
root: SimpleNode<T>;
constructor(data: T[]) {
this.root = createRoot<T>(data);
}
get data() {
return this.root.children?.map((node) => node.data) ?? [];
}
create(args: { parentId: string | null; index: number; data: T }) {
const parent = args.parentId ? this.find(args.parentId) : this.root;
if (!parent) return null;
parent.addChild(args.data, args.index);
}
move(args: { id: string; parentId: string | null; index: number }) {
const src = this.find(args.id);
const parent = args.parentId ? this.find(args.parentId) : this.root;
if (!src || !parent) return;
parent.addChild(src.data, args.index);
src.drop();
}
update(args: { id: string; changes: Partial<T> }) {
const node = this.find(args.id);
if (node) node.update(args.changes);
}
drop(args: { id: string }) {
const node = this.find(args.id);
if (node) node.drop();
}
find(id: string, node: SimpleNode<T> = this.root): SimpleNode<T> | null {
if (!node) return null;
if (node.id === id) return node as SimpleNode<T>;
if (node.children) {
for (let child of node.children) {
const found = this.find(id, child);
if (found) return found;
}
return null;
}
return null;
}
}
function createRoot<T extends SimpleData>(data: T[]) {
const root = new SimpleNode<T>({ id: "ROOT" } as T, null);
root.children = data.map((d) => createNode(d as T, root));
return root;
}
function createNode<T extends SimpleData>(data: T, parent: SimpleNode<T>) {
const node = new SimpleNode<T>(data, parent);
if (data.children)
node.children = data.children.map((d) => createNode<T>(d as T, node));
return node;
}
class SimpleNode<T extends SimpleData> {
id: string;
children?: SimpleNode<T>[];
constructor(public data: T, public parent: SimpleNode<T> | null) {
this.id = data.id;
}
hasParent(): this is this & { parent: SimpleNode<T> } {
return !!this.parent;
}
get childIndex(): number {
return this.hasParent() ? this.parent.children!.indexOf(this) : -1;
}
addChild(data: T, index: number) {
const node = createNode(data, this);
this.children = this.children ?? [];
this.children.splice(index, 0, node);
this.data.children = this.data.children ?? [];
this.data.children.splice(index, 0, data);
}
removeChild(index: number) {
this.children?.splice(index, 1);
this.data.children?.splice(index, 1);
}
update(changes: Partial<T>) {
if (this.hasParent()) {
const i = this.childIndex;
this.parent.addChild({ ...this.data, ...changes }, i);
this.drop();
}
}
drop() {
if (this.hasParent()) this.parent.removeChild(this.childIndex);
}
}

View File

@ -1,185 +1,185 @@
import { XYCoord } from "react-dnd";
import { NodeApi } from "../interfaces/node-api";
import {
bound,
indexOf,
isClosed,
isItem,
isOpenWithEmptyChildren,
} from "../utils";
import { DropResult } from "./drop-hook";
function measureHover(el: HTMLElement, offset: XYCoord) {
const rect = el.getBoundingClientRect();
const x = offset.x - Math.round(rect.x);
const y = offset.y - Math.round(rect.y);
const height = rect.height;
const inTopHalf = y < height / 2;
const inBottomHalf = !inTopHalf;
const pad = height / 4;
const inMiddle = y > pad && y < height - pad;
const atTop = !inMiddle && inTopHalf;
const atBottom = !inMiddle && inBottomHalf;
return { x, inTopHalf, inBottomHalf, inMiddle, atTop, atBottom };
}
type HoverData = ReturnType<typeof measureHover>;
function getNodesAroundCursor(
node: NodeApi | null,
prev: NodeApi | null,
next: NodeApi | null,
hover: HoverData
): [NodeApi | null, NodeApi | null] {
if (!node) {
// We're hovering over the empty part of the list, not over an item,
// Put the cursor below the last item which is "prev"
return [prev, null];
}
if (node.isInternal) {
if (hover.atTop) {
return [prev, node];
} else if (hover.inMiddle) {
return [node, node];
} else {
return [node, next];
}
} else {
if (hover.inTopHalf) {
return [prev, node];
} else {
return [node, next];
}
}
}
type Args = {
element: HTMLElement;
offset: XYCoord;
indent: number;
node: NodeApi | null;
prevNode: NodeApi | null;
nextNode: NodeApi | null;
};
export type ComputedDrop = {
drop: DropResult | null;
cursor: Cursor | null;
};
function dropAt(
parentId: string | undefined,
index: number | null
): DropResult {
return { parentId: parentId || null, index };
}
function lineCursor(index: number, level: number) {
return {
type: "line" as "line",
index,
level,
};
}
function noCursor() {
return {
type: "none" as "none",
};
}
function highlightCursor(id: string) {
return {
type: "highlight" as "highlight",
id,
};
}
function walkUpFrom(node: NodeApi, level: number) {
let drop = node;
while (drop.parent && drop.level > level) {
drop = drop.parent;
}
const parentId = drop.parent?.id || null;
const index = indexOf(drop) + 1;
return { parentId, index };
}
export type LineCursor = ReturnType<typeof lineCursor>;
export type NoCursor = ReturnType<typeof noCursor>;
export type HighlightCursor = ReturnType<typeof highlightCursor>;
export type Cursor = LineCursor | NoCursor | HighlightCursor;
/**
* This is the most complex, tricky function in the whole repo.
*/
export function computeDrop(args: Args): ComputedDrop {
const hover = measureHover(args.element, args.offset);
const indent = args.indent;
const hoverLevel = Math.round(Math.max(0, hover.x - indent) / indent);
const { node, nextNode, prevNode } = args;
const [above, below] = getNodesAroundCursor(node, prevNode, nextNode, hover);
/* Hovering over the middle of a folder */
if (node && node.isInternal && hover.inMiddle) {
return {
drop: dropAt(node.id, null),
cursor: highlightCursor(node.id),
};
}
/*
* Now we only need to care about the node above the cursor
* ----------- -------
*/
/* There is no node above the cursor line */
if (!above) {
return {
drop: dropAt(below?.parent?.id, 0),
cursor: lineCursor(0, 0),
};
}
/* The node above the cursor line is an item */
if (isItem(above)) {
const level = bound(hoverLevel, below?.level || 0, above.level);
return {
drop: walkUpFrom(above, level),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
/* The node above the cursor line is a closed folder */
if (isClosed(above)) {
const level = bound(hoverLevel, below?.level || 0, above.level);
return {
drop: walkUpFrom(above, level),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
/* The node above the cursor line is an open folder with no children */
if (isOpenWithEmptyChildren(above)) {
const level = bound(hoverLevel, 0, above.level + 1);
if (level > above.level) {
/* Will be the first child of the empty folder */
return {
drop: dropAt(above.id, 0),
cursor: lineCursor(above.rowIndex! + 1, level),
};
} else {
/* Will be a sibling or grandsibling of the empty folder */
return {
drop: walkUpFrom(above, level),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
}
/* The node above the cursor is a an open folder with children */
return {
drop: dropAt(above?.id, 0),
cursor: lineCursor(above.rowIndex! + 1, above.level + 1),
};
}
import { XYCoord } from "react-dnd";
import { NodeApi } from "../interfaces/node-api";
import {
bound,
indexOf,
isClosed,
isItem,
isOpenWithEmptyChildren,
} from "../utils";
import { DropResult } from "./drop-hook";
function measureHover(el: HTMLElement, offset: XYCoord) {
const rect = el.getBoundingClientRect();
const x = offset.x - Math.round(rect.x);
const y = offset.y - Math.round(rect.y);
const height = rect.height;
const inTopHalf = y < height / 2;
const inBottomHalf = !inTopHalf;
const pad = height / 4;
const inMiddle = y > pad && y < height - pad;
const atTop = !inMiddle && inTopHalf;
const atBottom = !inMiddle && inBottomHalf;
return { x, inTopHalf, inBottomHalf, inMiddle, atTop, atBottom };
}
type HoverData = ReturnType<typeof measureHover>;
function getNodesAroundCursor(
node: NodeApi | null,
prev: NodeApi | null,
next: NodeApi | null,
hover: HoverData
): [NodeApi | null, NodeApi | null] {
if (!node) {
// We're hovering over the empty part of the list, not over an item,
// Put the cursor below the last item which is "prev"
return [prev, null];
}
if (node.isInternal) {
if (hover.atTop) {
return [prev, node];
} else if (hover.inMiddle) {
return [node, node];
} else {
return [node, next];
}
} else {
if (hover.inTopHalf) {
return [prev, node];
} else {
return [node, next];
}
}
}
type Args = {
element: HTMLElement;
offset: XYCoord;
indent: number;
node: NodeApi | null;
prevNode: NodeApi | null;
nextNode: NodeApi | null;
};
export type ComputedDrop = {
drop: DropResult | null;
cursor: Cursor | null;
};
function dropAt(
parentId: string | undefined,
index: number | null
): DropResult {
return { parentId: parentId || null, index };
}
function lineCursor(index: number, level: number) {
return {
type: "line" as "line",
index,
level,
};
}
function noCursor() {
return {
type: "none" as "none",
};
}
function highlightCursor(id: string) {
return {
type: "highlight" as "highlight",
id,
};
}
function walkUpFrom(node: NodeApi, level: number) {
let drop = node;
while (drop.parent && drop.level > level) {
drop = drop.parent;
}
const parentId = drop.parent?.id || null;
const index = indexOf(drop) + 1;
return { parentId, index };
}
export type LineCursor = ReturnType<typeof lineCursor>;
export type NoCursor = ReturnType<typeof noCursor>;
export type HighlightCursor = ReturnType<typeof highlightCursor>;
export type Cursor = LineCursor | NoCursor | HighlightCursor;
/**
* This is the most complex, tricky function in the whole repo.
*/
export function computeDrop(args: Args): ComputedDrop {
const hover = measureHover(args.element, args.offset);
const indent = args.indent;
const hoverLevel = Math.round(Math.max(0, hover.x - indent) / indent);
const { node, nextNode, prevNode } = args;
const [above, below] = getNodesAroundCursor(node, prevNode, nextNode, hover);
/* Hovering over the middle of a folder */
if (node && node.isInternal && hover.inMiddle) {
return {
drop: dropAt(node.id, null),
cursor: highlightCursor(node.id),
};
}
/*
* Now we only need to care about the node above the cursor
* ----------- -------
*/
/* There is no node above the cursor line */
if (!above) {
return {
drop: dropAt(below?.parent?.id, 0),
cursor: lineCursor(0, 0),
};
}
/* The node above the cursor line is an item */
if (isItem(above)) {
const level = bound(hoverLevel, below?.level || 0, above.level);
return {
drop: walkUpFrom(above, level),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
/* The node above the cursor line is a closed folder */
if (isClosed(above)) {
const level = bound(hoverLevel, below?.level || 0, above.level);
return {
drop: walkUpFrom(above, level),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
/* The node above the cursor line is an open folder with no children */
if (isOpenWithEmptyChildren(above)) {
const level = bound(hoverLevel, 0, above.level + 1);
if (level > above.level) {
/* Will be the first child of the empty folder */
return {
drop: dropAt(above.id, 0),
cursor: lineCursor(above.rowIndex! + 1, level),
};
} else {
/* Will be a sibling or grandsibling of the empty folder */
return {
drop: walkUpFrom(above, level),
cursor: lineCursor(above.rowIndex! + 1, level),
};
}
}
/* The node above the cursor is a an open folder with children */
return {
drop: dropAt(above?.id, 0),
cursor: lineCursor(above.rowIndex! + 1, above.level + 1),
};
}

View File

@ -1,36 +1,36 @@
import { useEffect } from "react";
import { ConnectDragSource, useDrag } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTreeApi } from "../context";
import { NodeApi } from "../interfaces/node-api";
import { DragItem } from "../types/dnd";
import { DropResult } from "./drop-hook";
import { actions as dnd } from "../state/dnd-slice";
export function useDragHook<T>(node: NodeApi<T>): ConnectDragSource {
const tree = useTreeApi();
const ids = tree.selectedIds;
const [_, ref, preview] = useDrag<DragItem, DropResult, void>(
() => ({
canDrag: () => node.isDraggable,
type: "NODE",
item: () => {
// This is fired once at the begging of a drag operation
const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id];
tree.dispatch(dnd.dragStart(node.id, dragIds));
return { id: node.id, dragIds };
},
end: () => {
tree.hideCursor();
tree.dispatch(dnd.dragEnd());
},
}),
[ids, node],
);
useEffect(() => {
preview(getEmptyImage());
}, [preview]);
return ref;
}
import { useEffect } from "react";
import { ConnectDragSource, useDrag } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import { useTreeApi } from "../context";
import { NodeApi } from "../interfaces/node-api";
import { DragItem } from "../types/dnd";
import { DropResult } from "./drop-hook";
import { actions as dnd } from "../state/dnd-slice";
export function useDragHook<T>(node: NodeApi<T>): ConnectDragSource {
const tree = useTreeApi();
const ids = tree.selectedIds;
const [_, ref, preview] = useDrag<DragItem, DropResult, void>(
() => ({
canDrag: () => node.isDraggable,
type: "NODE",
item: () => {
// This is fired once at the begging of a drag operation
const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id];
tree.dispatch(dnd.dragStart(node.id, dragIds));
return { id: node.id, dragIds };
},
end: () => {
tree.hideCursor();
tree.dispatch(dnd.dragEnd());
},
}),
[ids, node],
);
useEffect(() => {
preview(getEmptyImage());
}, [preview]);
return ref;
}

View File

@ -1,61 +1,61 @@
import { RefObject } from "react";
import { ConnectDropTarget, useDrop } from "react-dnd";
import { useTreeApi } from "../context";
import { NodeApi } from "../interfaces/node-api";
import { DragItem } from "../types/dnd";
import { computeDrop } from "./compute-drop";
import { actions as dnd } from "../state/dnd-slice";
import { safeRun } from "../utils";
import { ROOT_ID } from "../data/create-root";
export type DropResult = {
parentId: string | null;
index: number | null;
};
export function useDropHook(
el: RefObject<HTMLElement | null>,
node: NodeApi<any>,
): ConnectDropTarget {
const tree = useTreeApi();
const [_, dropRef] = useDrop<DragItem, DropResult | null, void>(
() => ({
accept: "NODE",
canDrop: () => tree.canDrop(),
hover: (_item, m) => {
const offset = m.getClientOffset();
if (!el.current || !offset) return;
const { cursor, drop } = computeDrop({
element: el.current,
offset: offset,
indent: tree.indent,
node: node,
prevNode: node.prev,
nextNode: node.next,
});
if (drop) tree.dispatch(dnd.hovering(drop.parentId, drop.index));
if (m.canDrop()) {
if (cursor) tree.showCursor(cursor);
} else {
tree.hideCursor();
}
},
drop: (_, m) => {
if (!m.canDrop()) return null;
let { parentId, index, dragIds } = tree.state.dnd;
safeRun(tree.props.onMove, {
dragIds,
parentId: parentId === ROOT_ID ? null : parentId,
index: index === null ? 0 : index, // When it's null it was dropped over a folder
dragNodes: tree.dragNodes,
parentNode: tree.get(parentId),
});
tree.open(parentId);
},
}),
[node, el.current, tree.props],
);
return dropRef;
}
import { RefObject } from "react";
import { ConnectDropTarget, useDrop } from "react-dnd";
import { useTreeApi } from "../context";
import { NodeApi } from "../interfaces/node-api";
import { DragItem } from "../types/dnd";
import { computeDrop } from "./compute-drop";
import { actions as dnd } from "../state/dnd-slice";
import { safeRun } from "../utils";
import { ROOT_ID } from "../data/create-root";
export type DropResult = {
parentId: string | null;
index: number | null;
};
export function useDropHook(
el: RefObject<HTMLElement | null>,
node: NodeApi<any>,
): ConnectDropTarget {
const tree = useTreeApi();
const [_, dropRef] = useDrop<DragItem, DropResult | null, void>(
() => ({
accept: "NODE",
canDrop: () => tree.canDrop(),
hover: (_item, m) => {
const offset = m.getClientOffset();
if (!el.current || !offset) return;
const { cursor, drop } = computeDrop({
element: el.current,
offset: offset,
indent: tree.indent,
node: node,
prevNode: node.prev,
nextNode: node.next,
});
if (drop) tree.dispatch(dnd.hovering(drop.parentId, drop.index));
if (m.canDrop()) {
if (cursor) tree.showCursor(cursor);
} else {
tree.hideCursor();
}
},
drop: (_, m) => {
if (!m.canDrop()) return null;
let { parentId, index, dragIds } = tree.state.dnd;
safeRun(tree.props.onMove, {
dragIds,
parentId: parentId === ROOT_ID ? null : parentId,
index: index === null ? 0 : index, // When it's null it was dropped over a folder
dragNodes: tree.dragNodes,
parentNode: tree.get(parentId),
});
tree.open(parentId);
},
}),
[node, el.current, tree.props],
);
return dropRef;
}

View File

@ -1,26 +1,26 @@
import { XYCoord } from "react-dnd";
import { bound } from "../utils";
export function measureHover(el: HTMLElement, offset: XYCoord, indent: number) {
const nextEl = el.nextElementSibling as HTMLElement | null;
const prevEl = el.previousElementSibling as HTMLElement | null;
const rect = el.getBoundingClientRect();
const x = offset.x - Math.round(rect.x);
const y = offset.y - Math.round(rect.y);
const height = rect.height;
const inTopHalf = y < height / 2;
const inBottomHalf = !inTopHalf;
const pad = height / 4;
const inMiddle = y > pad && y < height - pad;
const maxLevel = Number(
inBottomHalf ? el.dataset.level : prevEl ? prevEl.dataset.level : 0
);
const minLevel = Number(
inTopHalf ? el.dataset.level : nextEl ? nextEl.dataset.level : 0
);
const level = bound(Math.floor(x / indent), minLevel, maxLevel);
return { level, inTopHalf, inBottomHalf, inMiddle };
}
export type HoverData = ReturnType<typeof measureHover>;
import { XYCoord } from "react-dnd";
import { bound } from "../utils";
export function measureHover(el: HTMLElement, offset: XYCoord, indent: number) {
const nextEl = el.nextElementSibling as HTMLElement | null;
const prevEl = el.previousElementSibling as HTMLElement | null;
const rect = el.getBoundingClientRect();
const x = offset.x - Math.round(rect.x);
const y = offset.y - Math.round(rect.y);
const height = rect.height;
const inTopHalf = y < height / 2;
const inBottomHalf = !inTopHalf;
const pad = height / 4;
const inMiddle = y > pad && y < height - pad;
const maxLevel = Number(
inBottomHalf ? el.dataset.level : prevEl ? prevEl.dataset.level : 0
);
const minLevel = Number(
inTopHalf ? el.dataset.level : nextEl ? nextEl.dataset.level : 0
);
const level = bound(Math.floor(x / indent), minLevel, maxLevel);
return { level, inTopHalf, inBottomHalf, inMiddle };
}
export type HoverData = ReturnType<typeof measureHover>;

View File

@ -1,44 +1,44 @@
import { useDrop } from "react-dnd";
import { useTreeApi } from "../context";
import { DragItem } from "../types/dnd";
import { computeDrop } from "./compute-drop";
import { DropResult } from "./drop-hook";
import { actions as dnd } from "../state/dnd-slice";
export function useOuterDrop() {
const tree = useTreeApi();
// In case we drop an item at the bottom of the list
const [, drop] = useDrop<DragItem, DropResult | null, { isOver: boolean }>(
() => ({
accept: "NODE",
canDrop: (_item, m) => {
if (!m.isOver({ shallow: true })) return false;
return tree.canDrop();
},
hover: (_item, m) => {
if (!m.isOver({ shallow: true })) return;
const offset = m.getClientOffset();
if (!tree.listEl.current || !offset) return;
const { cursor, drop } = computeDrop({
element: tree.listEl.current,
offset: offset,
indent: tree.indent,
node: null,
prevNode: tree.visibleNodes[tree.visibleNodes.length - 1],
nextNode: null,
});
if (drop) tree.dispatch(dnd.hovering(drop.parentId, drop.index));
if (m.canDrop()) {
if (cursor) tree.showCursor(cursor);
} else {
tree.hideCursor();
}
},
}),
[tree]
);
drop(tree.listEl);
}
import { useDrop } from "react-dnd";
import { useTreeApi } from "../context";
import { DragItem } from "../types/dnd";
import { computeDrop } from "./compute-drop";
import { DropResult } from "./drop-hook";
import { actions as dnd } from "../state/dnd-slice";
export function useOuterDrop() {
const tree = useTreeApi();
// In case we drop an item at the bottom of the list
const [, drop] = useDrop<DragItem, DropResult | null, { isOver: boolean }>(
() => ({
accept: "NODE",
canDrop: (_item, m) => {
if (!m.isOver({ shallow: true })) return false;
return tree.canDrop();
},
hover: (_item, m) => {
if (!m.isOver({ shallow: true })) return;
const offset = m.getClientOffset();
if (!tree.listEl.current || !offset) return;
const { cursor, drop } = computeDrop({
element: tree.listEl.current,
offset: offset,
indent: tree.indent,
node: null,
prevNode: tree.visibleNodes[tree.visibleNodes.length - 1],
nextNode: null,
});
if (drop) tree.dispatch(dnd.hovering(drop.parentId, drop.index));
if (m.canDrop()) {
if (cursor) tree.showCursor(cursor);
} else {
tree.hideCursor();
}
},
}),
[tree]
);
drop(tree.listEl);
}

View File

@ -1,16 +1,16 @@
import { useMemo } from "react";
import { useTreeApi } from "../context";
import { IdObj } from "../types/utils";
export function useFreshNode<T>(index: number) {
const tree = useTreeApi<T>();
const original = tree.at(index);
if (!original) throw new Error(`Could not find node for index: ${index}`);
return useMemo(() => {
const fresh = original.clone();
tree.visibleNodes[index] = fresh; // sneaky
return fresh;
// Return a fresh instance if the state values change
}, [...Object.values(original.state), original]);
}
import { useMemo } from "react";
import { useTreeApi } from "../context";
import { IdObj } from "../types/utils";
export function useFreshNode<T>(index: number) {
const tree = useTreeApi<T>();
const original = tree.at(index);
if (!original) throw new Error(`Could not find node for index: ${index}`);
return useMemo(() => {
const fresh = original.clone();
tree.visibleNodes[index] = fresh; // sneaky
return fresh;
// Return a fresh instance if the state values change
}, [...Object.values(original.state), original]);
}

View File

@ -1,60 +1,60 @@
import { useMemo, useState } from "react";
import { SimpleTree } from "../data/simple-tree";
import {
CreateHandler,
DeleteHandler,
MoveHandler,
RenameHandler,
} from "../types/handlers";
import { IdObj } from "../types/utils";
export type SimpleTreeData = {
id: string;
name: string;
children?: SimpleTreeData[];
};
let nextId = 0;
export function useSimpleTree<T>(initialData: readonly T[]) {
const [data, setData] = useState(initialData);
const tree = useMemo(
() =>
new SimpleTree<// @ts-ignore
T>(data),
[data]
);
const onMove: MoveHandler<T> = (args: {
dragIds: string[];
parentId: null | string;
index: number;
}) => {
for (const id of args.dragIds) {
tree.move({ id, parentId: args.parentId, index: args.index });
}
setData(tree.data);
};
const onRename: RenameHandler<T> = ({ name, id }) => {
tree.update({ id, changes: { name } as any });
setData(tree.data);
};
const onCreate: CreateHandler<T> = ({ parentId, index, type }) => {
const data = { id: `simple-tree-id-${nextId++}`, name: "" } as any;
if (type === "internal") data.children = [];
tree.create({ parentId, index, data });
setData(tree.data);
return data;
};
const onDelete: DeleteHandler<T> = (args: { ids: string[] }) => {
args.ids.forEach((id) => tree.drop({ id }));
setData(tree.data);
};
const controller = { onMove, onRename, onCreate, onDelete };
return [data, controller] as const;
}
import { useMemo, useState } from "react";
import { SimpleTree } from "../data/simple-tree";
import {
CreateHandler,
DeleteHandler,
MoveHandler,
RenameHandler,
} from "../types/handlers";
import { IdObj } from "../types/utils";
export type SimpleTreeData = {
id: string;
name: string;
children?: SimpleTreeData[];
};
let nextId = 0;
export function useSimpleTree<T>(initialData: readonly T[]) {
const [data, setData] = useState(initialData);
const tree = useMemo(
() =>
new SimpleTree<// @ts-ignore
T>(data),
[data]
);
const onMove: MoveHandler<T> = (args: {
dragIds: string[];
parentId: null | string;
index: number;
}) => {
for (const id of args.dragIds) {
tree.move({ id, parentId: args.parentId, index: args.index });
}
setData(tree.data);
};
const onRename: RenameHandler<T> = ({ name, id }) => {
tree.update({ id, changes: { name } as any });
setData(tree.data);
};
const onCreate: CreateHandler<T> = ({ parentId, index, type }) => {
const data = { id: `simple-tree-id-${nextId++}`, name: "" } as any;
if (type === "internal") data.children = [];
tree.create({ parentId, index, data });
setData(tree.data);
return data;
};
const onDelete: DeleteHandler<T> = (args: { ids: string[] }) => {
args.ids.forEach((id) => tree.drop({ id }));
setData(tree.data);
};
const controller = { onMove, onRename, onCreate, onDelete };
return [data, controller] as const;
}

View File

@ -1,33 +1,33 @@
import { TreeProps } from "../types/tree-props";
import { IdObj } from "../types/utils";
import { SimpleTreeData, useSimpleTree } from "./use-simple-tree";
export function useValidatedProps<T>(props: TreeProps<T>): TreeProps<T> {
if (props.initialData && props.data) {
throw new Error(
`React Arborist Tree => Provide either a data or initialData prop, but not both.`
);
}
if (
props.initialData &&
(props.onCreate || props.onDelete || props.onMove || props.onRename)
) {
throw new Error(
`React Arborist Tree => You passed the initialData prop along with a data handler.
Use the data prop if you want to provide your own handlers.`
);
}
if (props.initialData) {
/**
* Let's break the rules of hooks here. If the initialData prop
* is provided, we will assume it will not change for the life of
* the component.
*
* We will provide the real data and the handlers to update it.
* */
const [data, controller] = useSimpleTree<T>(props.initialData);
return { ...props, ...controller, data };
} else {
return props;
}
}
import { TreeProps } from "../types/tree-props";
import { IdObj } from "../types/utils";
import { SimpleTreeData, useSimpleTree } from "./use-simple-tree";
export function useValidatedProps<T>(props: TreeProps<T>): TreeProps<T> {
if (props.initialData && props.data) {
throw new Error(
`React Arborist Tree => Provide either a data or initialData prop, but not both.`
);
}
if (
props.initialData &&
(props.onCreate || props.onDelete || props.onMove || props.onRename)
) {
throw new Error(
`React Arborist Tree => You passed the initialData prop along with a data handler.
Use the data prop if you want to provide your own handlers.`
);
}
if (props.initialData) {
/**
* Let's break the rules of hooks here. If the initialData prop
* is provided, we will assume it will not change for the life of
* the component.
*
* We will provide the real data and the handlers to update it.
* */
const [data, controller] = useSimpleTree<T>(props.initialData);
return { ...props, ...controller, data };
} else {
return props;
}
}

View File

@ -1,9 +1,9 @@
/* The Public Api */
export { Tree } from "./components/tree";
export * from "./types/handlers";
export * from "./types/renderers";
export * from "./types/state";
export * from "./interfaces/node-api";
export * from "./interfaces/tree-api";
export * from "./data/simple-tree";
export * from "./hooks/use-simple-tree";
/* The Public Api */
export { Tree } from "./components/tree";
export * from "./types/handlers";
export * from "./types/renderers";
export * from "./types/state";
export * from "./interfaces/node-api";
export * from "./interfaces/tree-api";
export * from "./data/simple-tree";
export * from "./hooks/use-simple-tree";

View File

@ -1,209 +1,209 @@
import React from "react";
import { TreeApi } from "./tree-api";
import { IdObj } from "../types/utils";
import { ROOT_ID } from "../data/create-root";
type Params<T> = {
id: string;
data: T;
level: number;
children: NodeApi<T>[] | null;
parent: NodeApi<T> | null;
isDraggable: boolean;
rowIndex: number | null;
tree: TreeApi<T>;
};
export class NodeApi<T = any> {
tree: TreeApi<T>;
id: string;
data: T;
level: number;
children: NodeApi<T>[] | null;
parent: NodeApi<T> | null;
isDraggable: boolean;
rowIndex: number | null;
constructor(params: Params<T>) {
this.tree = params.tree;
this.id = params.id;
this.data = params.data;
this.level = params.level;
this.children = params.children;
this.parent = params.parent;
this.isDraggable = params.isDraggable;
this.rowIndex = params.rowIndex;
}
get isRoot() {
return this.id === ROOT_ID;
}
get isLeaf() {
return !Array.isArray(this.children);
}
get isInternal() {
return !this.isLeaf;
}
get isOpen() {
return this.isLeaf ? false : this.tree.isOpen(this.id);
}
get isClosed() {
return this.isLeaf ? false : !this.tree.isOpen(this.id);
}
get isEditable() {
return this.tree.isEditable(this.data);
}
get isEditing() {
return this.tree.editingId === this.id;
}
get isSelected() {
return this.tree.isSelected(this.id);
}
get isOnlySelection() {
return this.isSelected && this.tree.hasOneSelection;
}
get isSelectedStart() {
return this.isSelected && !this.prev?.isSelected;
}
get isSelectedEnd() {
return this.isSelected && !this.next?.isSelected;
}
get isFocused() {
return this.tree.isFocused(this.id);
}
get isDragging() {
return this.tree.isDragging(this.id);
}
get willReceiveDrop() {
return this.tree.willReceiveDrop(this.id);
}
get state() {
return {
isClosed: this.isClosed,
isDragging: this.isDragging,
isEditing: this.isEditing,
isFocused: this.isFocused,
isInternal: this.isInternal,
isLeaf: this.isLeaf,
isOpen: this.isOpen,
isSelected: this.isSelected,
isSelectedEnd: this.isSelectedEnd,
isSelectedStart: this.isSelectedStart,
willReceiveDrop: this.willReceiveDrop,
};
}
get childIndex() {
if (this.parent && this.parent.children) {
return this.parent.children.findIndex((child) => child.id === this.id);
} else {
return -1;
}
}
get next(): NodeApi<T> | null {
if (this.rowIndex === null) return null;
return this.tree.at(this.rowIndex + 1);
}
get prev(): NodeApi<T> | null {
if (this.rowIndex === null) return null;
return this.tree.at(this.rowIndex - 1);
}
get nextSibling(): NodeApi<T> | null {
const i = this.childIndex;
return this.parent?.children![i + 1] ?? null;
}
isAncestorOf(node: NodeApi<T> | null) {
if (!node) return false;
let ancestor: NodeApi<T> | null = node;
while (ancestor) {
if (ancestor.id === this.id) return true;
ancestor = ancestor.parent;
}
return false;
}
select() {
this.tree.select(this);
}
deselect() {
this.tree.deselect(this);
}
selectMulti() {
this.tree.selectMulti(this);
}
selectContiguous() {
this.tree.selectContiguous(this);
}
activate() {
this.tree.activate(this);
}
focus() {
this.tree.focus(this);
}
toggle() {
this.tree.toggle(this);
}
open() {
this.tree.open(this);
}
openParents() {
this.tree.openParents(this);
}
close() {
this.tree.close(this);
}
submit(value: string) {
this.tree.submit(this, value);
}
reset() {
this.tree.reset();
}
clone() {
return new NodeApi<T>({ ...this });
}
edit() {
return this.tree.edit(this);
}
handleClick = (e: React.MouseEvent) => {
if (e.metaKey && !this.tree.props.disableMultiSelection) {
this.isSelected ? this.deselect() : this.selectMulti();
} else if (e.shiftKey && !this.tree.props.disableMultiSelection) {
this.selectContiguous();
} else {
this.select();
this.activate();
}
};
}
import React from "react";
import { TreeApi } from "./tree-api";
import { IdObj } from "../types/utils";
import { ROOT_ID } from "../data/create-root";
type Params<T> = {
id: string;
data: T;
level: number;
children: NodeApi<T>[] | null;
parent: NodeApi<T> | null;
isDraggable: boolean;
rowIndex: number | null;
tree: TreeApi<T>;
};
export class NodeApi<T = any> {
tree: TreeApi<T>;
id: string;
data: T;
level: number;
children: NodeApi<T>[] | null;
parent: NodeApi<T> | null;
isDraggable: boolean;
rowIndex: number | null;
constructor(params: Params<T>) {
this.tree = params.tree;
this.id = params.id;
this.data = params.data;
this.level = params.level;
this.children = params.children;
this.parent = params.parent;
this.isDraggable = params.isDraggable;
this.rowIndex = params.rowIndex;
}
get isRoot() {
return this.id === ROOT_ID;
}
get isLeaf() {
return !Array.isArray(this.children);
}
get isInternal() {
return !this.isLeaf;
}
get isOpen() {
return this.isLeaf ? false : this.tree.isOpen(this.id);
}
get isClosed() {
return this.isLeaf ? false : !this.tree.isOpen(this.id);
}
get isEditable() {
return this.tree.isEditable(this.data);
}
get isEditing() {
return this.tree.editingId === this.id;
}
get isSelected() {
return this.tree.isSelected(this.id);
}
get isOnlySelection() {
return this.isSelected && this.tree.hasOneSelection;
}
get isSelectedStart() {
return this.isSelected && !this.prev?.isSelected;
}
get isSelectedEnd() {
return this.isSelected && !this.next?.isSelected;
}
get isFocused() {
return this.tree.isFocused(this.id);
}
get isDragging() {
return this.tree.isDragging(this.id);
}
get willReceiveDrop() {
return this.tree.willReceiveDrop(this.id);
}
get state() {
return {
isClosed: this.isClosed,
isDragging: this.isDragging,
isEditing: this.isEditing,
isFocused: this.isFocused,
isInternal: this.isInternal,
isLeaf: this.isLeaf,
isOpen: this.isOpen,
isSelected: this.isSelected,
isSelectedEnd: this.isSelectedEnd,
isSelectedStart: this.isSelectedStart,
willReceiveDrop: this.willReceiveDrop,
};
}
get childIndex() {
if (this.parent && this.parent.children) {
return this.parent.children.findIndex((child) => child.id === this.id);
} else {
return -1;
}
}
get next(): NodeApi<T> | null {
if (this.rowIndex === null) return null;
return this.tree.at(this.rowIndex + 1);
}
get prev(): NodeApi<T> | null {
if (this.rowIndex === null) return null;
return this.tree.at(this.rowIndex - 1);
}
get nextSibling(): NodeApi<T> | null {
const i = this.childIndex;
return this.parent?.children![i + 1] ?? null;
}
isAncestorOf(node: NodeApi<T> | null) {
if (!node) return false;
let ancestor: NodeApi<T> | null = node;
while (ancestor) {
if (ancestor.id === this.id) return true;
ancestor = ancestor.parent;
}
return false;
}
select() {
this.tree.select(this);
}
deselect() {
this.tree.deselect(this);
}
selectMulti() {
this.tree.selectMulti(this);
}
selectContiguous() {
this.tree.selectContiguous(this);
}
activate() {
this.tree.activate(this);
}
focus() {
this.tree.focus(this);
}
toggle() {
this.tree.toggle(this);
}
open() {
this.tree.open(this);
}
openParents() {
this.tree.openParents(this);
}
close() {
this.tree.close(this);
}
submit(value: string) {
this.tree.submit(this, value);
}
reset() {
this.tree.reset();
}
clone() {
return new NodeApi<T>({ ...this });
}
edit() {
return this.tree.edit(this);
}
handleClick = (e: React.MouseEvent) => {
if (e.metaKey && !this.tree.props.disableMultiSelection) {
this.isSelected ? this.deselect() : this.selectMulti();
} else if (e.shiftKey && !this.tree.props.disableMultiSelection) {
this.selectContiguous();
} else {
this.select();
this.activate();
}
};
}

View File

@ -1,15 +1,15 @@
import { createStore } from "redux";
import { rootReducer } from "../state/root-reducer";
import { TreeProps } from "../types/tree-props";
import { TreeApi } from "./tree-api";
function setupApi(props: TreeProps<any>) {
const store = createStore(rootReducer);
return new TreeApi(store, props, { current: null }, { current: null });
}
test("tree.canDrop()", () => {
expect(setupApi({ disableDrop: true }).canDrop()).toBe(false);
expect(setupApi({ disableDrop: () => false }).canDrop()).toBe(true);
expect(setupApi({ disableDrop: false }).canDrop()).toBe(true);
});
import { createStore } from "redux";
import { rootReducer } from "../state/root-reducer";
import { TreeProps } from "../types/tree-props";
import { TreeApi } from "./tree-api";
function setupApi(props: TreeProps<any>) {
const store = createStore(rootReducer);
return new TreeApi(store, props, { current: null }, { current: null });
}
test("tree.canDrop()", () => {
expect(setupApi({ disableDrop: true }).canDrop()).toBe(false);
expect(setupApi({ disableDrop: () => false }).canDrop()).toBe(true);
expect(setupApi({ disableDrop: false }).canDrop()).toBe(true);
});

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +1,47 @@
import { Cursor } from "../dnd/compute-drop";
import { ActionTypes } from "../types/utils";
import { initialState } from "./initial";
/* Types */
export type DndState = {
dragId: null | string;
cursor: Cursor;
dragIds: string[];
parentId: null | string;
index: number | null;
};
/* Actions */
export const actions = {
cursor(cursor: Cursor) {
return { type: "DND_CURSOR" as const, cursor };
},
dragStart(id: string, dragIds: string[]) {
return { type: "DND_DRAG_START" as const, id, dragIds };
},
dragEnd() {
return { type: "DND_DRAG_END" as const };
},
hovering(parentId: string | null, index: number | null) {
return { type: "DND_HOVERING" as const, parentId, index };
},
};
/* Reducer */
export function reducer(
state: DndState = initialState()["dnd"],
action: ActionTypes<typeof actions>
): DndState {
switch (action.type) {
case "DND_CURSOR":
return { ...state, cursor: action.cursor };
case "DND_DRAG_START":
return { ...state, dragId: action.id, dragIds: action.dragIds };
case "DND_DRAG_END":
return initialState()["dnd"];
case "DND_HOVERING":
return { ...state, parentId: action.parentId, index: action.index };
default:
return state;
}
}
import { Cursor } from "../dnd/compute-drop";
import { ActionTypes } from "../types/utils";
import { initialState } from "./initial";
/* Types */
export type DndState = {
dragId: null | string;
cursor: Cursor;
dragIds: string[];
parentId: null | string;
index: number | null;
};
/* Actions */
export const actions = {
cursor(cursor: Cursor) {
return { type: "DND_CURSOR" as const, cursor };
},
dragStart(id: string, dragIds: string[]) {
return { type: "DND_DRAG_START" as const, id, dragIds };
},
dragEnd() {
return { type: "DND_DRAG_END" as const };
},
hovering(parentId: string | null, index: number | null) {
return { type: "DND_HOVERING" as const, parentId, index };
},
};
/* Reducer */
export function reducer(
state: DndState = initialState()["dnd"],
action: ActionTypes<typeof actions>
): DndState {
switch (action.type) {
case "DND_CURSOR":
return { ...state, cursor: action.cursor };
case "DND_DRAG_START":
return { ...state, dragId: action.id, dragIds: action.dragIds };
case "DND_DRAG_END":
return initialState()["dnd"];
case "DND_HOVERING":
return { ...state, parentId: action.parentId, index: action.index };
default:
return state;
}
}

View File

@ -1,47 +1,47 @@
import { ActionTypes } from "../types/utils";
import { actions as dnd } from "./dnd-slice";
import { initialState } from "./initial";
/* Types */
export type DragSlice = {
id: string | null;
selectedIds: string[];
destinationParentId: string | null;
destinationIndex: number | null;
};
/* Reducer */
export function reducer(
state: DragSlice = initialState().nodes.drag,
action: ActionTypes<typeof dnd>
): DragSlice {
switch (action.type) {
case "DND_DRAG_START":
return { ...state, id: action.id, selectedIds: action.dragIds };
case "DND_DRAG_END":
return {
...state,
id: null,
destinationParentId: null,
destinationIndex: null,
selectedIds: [],
};
case "DND_HOVERING":
if (
action.parentId !== state.destinationParentId ||
action.index != state.destinationIndex
) {
return {
...state,
destinationParentId: action.parentId,
destinationIndex: action.index,
};
} else {
return state;
}
default:
return state;
}
}
import { ActionTypes } from "../types/utils";
import { actions as dnd } from "./dnd-slice";
import { initialState } from "./initial";
/* Types */
export type DragSlice = {
id: string | null;
selectedIds: string[];
destinationParentId: string | null;
destinationIndex: number | null;
};
/* Reducer */
export function reducer(
state: DragSlice = initialState().nodes.drag,
action: ActionTypes<typeof dnd>
): DragSlice {
switch (action.type) {
case "DND_DRAG_START":
return { ...state, id: action.id, selectedIds: action.dragIds };
case "DND_DRAG_END":
return {
...state,
id: null,
destinationParentId: null,
destinationIndex: null,
selectedIds: [],
};
case "DND_HOVERING":
if (
action.parentId !== state.destinationParentId ||
action.index != state.destinationIndex
) {
return {
...state,
destinationParentId: action.parentId,
destinationIndex: action.index,
};
} else {
return state;
}
default:
return state;
}
}

View File

@ -1,19 +1,19 @@
/* Types */
export type EditState = { id: string | null };
/* Actions */
export function edit(id: string | null) {
return { type: "EDIT" as const, id };
}
/* Reducer */
export function reducer(
state: EditState = { id: null },
action: ReturnType<typeof edit>
) {
if (action.type === "EDIT") {
return { ...state, id: action.id };
} else {
return state;
}
}
/* Types */
export type EditState = { id: string | null };
/* Actions */
export function edit(id: string | null) {
return { type: "EDIT" as const, id };
}
/* Reducer */
export function reducer(
state: EditState = { id: null },
action: ReturnType<typeof edit>
) {
if (action.type === "EDIT") {
return { ...state, id: action.id };
} else {
return state;
}
}

View File

@ -1,28 +1,28 @@
/* Types */
export type FocusState = { id: string | null; treeFocused: boolean };
/* Actions */
export function focus(id: string | null) {
return { type: "FOCUS" as const, id };
}
export function treeBlur() {
return { type: "TREE_BLUR" } as const;
}
/* Reducer */
export function reducer(
state: FocusState = { id: null, treeFocused: false },
action: ReturnType<typeof focus> | ReturnType<typeof treeBlur>
) {
if (action.type === "FOCUS") {
return { ...state, id: action.id, treeFocused: true };
} else if (action.type === "TREE_BLUR") {
return { ...state, treeFocused: false };
} else {
return state;
}
}
/* Types */
export type FocusState = { id: string | null; treeFocused: boolean };
/* Actions */
export function focus(id: string | null) {
return { type: "FOCUS" as const, id };
}
export function treeBlur() {
return { type: "TREE_BLUR" } as const;
}
/* Reducer */
export function reducer(
state: FocusState = { id: null, treeFocused: false },
action: ReturnType<typeof focus> | ReturnType<typeof treeBlur>
) {
if (action.type === "FOCUS") {
return { ...state, id: action.id, treeFocused: true };
} else if (action.type === "TREE_BLUR") {
return { ...state, treeFocused: false };
} else {
return state;
}
}

View File

@ -1,25 +1,25 @@
import { TreeProps } from "../types/tree-props";
import { RootState } from "./root-reducer";
export const initialState = (props?: TreeProps<any>): RootState => ({
nodes: {
// Changes together
open: { filtered: {}, unfiltered: props?.initialOpenState ?? {} },
focus: { id: null, treeFocused: false },
edit: { id: null },
drag: {
id: null,
selectedIds: [],
destinationParentId: null,
destinationIndex: null,
},
selection: { ids: new Set(), anchor: null, mostRecent: null },
},
dnd: {
cursor: { type: "none" },
dragId: null,
dragIds: [],
parentId: null,
index: -1,
},
});
import { TreeProps } from "../types/tree-props";
import { RootState } from "./root-reducer";
export const initialState = (props?: TreeProps<any>): RootState => ({
nodes: {
// Changes together
open: { filtered: {}, unfiltered: props?.initialOpenState ?? {} },
focus: { id: null, treeFocused: false },
edit: { id: null },
drag: {
id: null,
selectedIds: [],
destinationParentId: null,
destinationIndex: null,
},
selection: { ids: new Set(), anchor: null, mostRecent: null },
},
dnd: {
cursor: { type: "none" },
dragId: null,
dragIds: [],
parentId: null,
index: -1,
},
});

View File

@ -1,53 +1,53 @@
import { ActionTypes } from "../types/utils";
/* Types */
export type OpenMap = { [id: string]: boolean };
export type OpenSlice = { unfiltered: OpenMap; filtered: OpenMap };
/* Actions */
export const actions = {
open(id: string, filtered: boolean) {
return { type: "VISIBILITY_OPEN" as const, id, filtered };
},
close(id: string, filtered: boolean) {
return { type: "VISIBILITY_CLOSE" as const, id, filtered };
},
toggle(id: string, filtered: boolean) {
return { type: "VISIBILITY_TOGGLE" as const, id, filtered };
},
clear(filtered: boolean) {
return { type: "VISIBILITY_CLEAR" as const, filtered };
},
};
/* Reducer */
function openMapReducer(
state: OpenMap = {},
action: ActionTypes<typeof actions>
) {
if (action.type === "VISIBILITY_OPEN") {
return { ...state, [action.id]: true };
} else if (action.type === "VISIBILITY_CLOSE") {
return { ...state, [action.id]: false };
} else if (action.type === "VISIBILITY_TOGGLE") {
const prev = state[action.id];
return { ...state, [action.id]: !prev };
} else if (action.type === "VISIBILITY_CLEAR") {
return {};
} else {
return state;
}
}
export function reducer(
state: OpenSlice = { filtered: {}, unfiltered: {} },
action: ActionTypes<typeof actions>
): OpenSlice {
if (!action.type.startsWith("VISIBILITY")) return state;
if (action.filtered) {
return { ...state, filtered: openMapReducer(state.filtered, action) };
} else {
return { ...state, unfiltered: openMapReducer(state.unfiltered, action) };
}
}
import { ActionTypes } from "../types/utils";
/* Types */
export type OpenMap = { [id: string]: boolean };
export type OpenSlice = { unfiltered: OpenMap; filtered: OpenMap };
/* Actions */
export const actions = {
open(id: string, filtered: boolean) {
return { type: "VISIBILITY_OPEN" as const, id, filtered };
},
close(id: string, filtered: boolean) {
return { type: "VISIBILITY_CLOSE" as const, id, filtered };
},
toggle(id: string, filtered: boolean) {
return { type: "VISIBILITY_TOGGLE" as const, id, filtered };
},
clear(filtered: boolean) {
return { type: "VISIBILITY_CLEAR" as const, filtered };
},
};
/* Reducer */
function openMapReducer(
state: OpenMap = {},
action: ActionTypes<typeof actions>
) {
if (action.type === "VISIBILITY_OPEN") {
return { ...state, [action.id]: true };
} else if (action.type === "VISIBILITY_CLOSE") {
return { ...state, [action.id]: false };
} else if (action.type === "VISIBILITY_TOGGLE") {
const prev = state[action.id];
return { ...state, [action.id]: !prev };
} else if (action.type === "VISIBILITY_CLEAR") {
return {};
} else {
return state;
}
}
export function reducer(
state: OpenSlice = { filtered: {}, unfiltered: {} },
action: ActionTypes<typeof actions>
): OpenSlice {
if (!action.type.startsWith("VISIBILITY")) return state;
if (action.filtered) {
return { ...state, filtered: openMapReducer(state.filtered, action) };
} else {
return { ...state, unfiltered: openMapReducer(state.unfiltered, action) };
}
}

View File

@ -1,21 +1,21 @@
import { ActionFromReducer, combineReducers } from "redux";
import { reducer as focus } from "./focus-slice";
import { reducer as edit } from "./edit-slice";
import { reducer as dnd } from "./dnd-slice";
import { reducer as selection } from "./selection-slice";
import { reducer as open } from "./open-slice";
import { reducer as drag } from "./drag-slice";
export const rootReducer = combineReducers({
nodes: combineReducers({
focus,
edit,
open,
selection,
drag,
}),
dnd,
});
export type RootState = ReturnType<typeof rootReducer>;
export type Actions = ActionFromReducer<typeof rootReducer>;
import { ActionFromReducer, combineReducers } from "redux";
import { reducer as focus } from "./focus-slice";
import { reducer as edit } from "./edit-slice";
import { reducer as dnd } from "./dnd-slice";
import { reducer as selection } from "./selection-slice";
import { reducer as open } from "./open-slice";
import { reducer as drag } from "./drag-slice";
export const rootReducer = combineReducers({
nodes: combineReducers({
focus,
edit,
open,
selection,
drag,
}),
dnd,
});
export type RootState = ReturnType<typeof rootReducer>;
export type Actions = ActionFromReducer<typeof rootReducer>;

View File

@ -1,84 +1,84 @@
import { ActionTypes, IdObj } from "../types/utils";
import { identify } from "../utils";
import { initialState } from "./initial";
/* Types */
export type SelectionState = {
ids: Set<string>;
anchor: string | null;
mostRecent: string | null;
};
/* Actions */
export const actions = {
clear: () => ({ type: "SELECTION_CLEAR" as const }),
only: (id: string | IdObj) => ({
type: "SELECTION_ONLY" as const,
id: identify(id),
}),
add: (id: string | string[] | IdObj | IdObj[]) => ({
type: "SELECTION_ADD" as const,
ids: (Array.isArray(id) ? id : [id]).map(identify),
}),
remove: (id: string | string[] | IdObj | IdObj[]) => ({
type: "SELECTION_REMOVE" as const,
ids: (Array.isArray(id) ? id : [id]).map(identify),
}),
set: (args: {
ids: Set<string>;
anchor: string | null;
mostRecent: string | null;
}) => ({
type: "SELECTION_SET" as const,
...args,
}),
mostRecent: (id: string | null | IdObj) => ({
type: "SELECTION_MOST_RECENT" as const,
id: id === null ? null : identify(id),
}),
anchor: (id: string | null | IdObj) => ({
type: "SELECTION_ANCHOR" as const,
id: id === null ? null : identify(id),
}),
};
/* Reducer */
export function reducer(
state: SelectionState = initialState()["nodes"]["selection"],
action: ActionTypes<typeof actions>
): SelectionState {
const ids = state.ids;
switch (action.type) {
case "SELECTION_CLEAR":
return { ...state, ids: new Set() };
case "SELECTION_ONLY":
return { ...state, ids: new Set([action.id]) };
case "SELECTION_ADD":
if (action.ids.length === 0) return state;
action.ids.forEach((id) => ids.add(id));
return { ...state, ids: new Set(ids) };
case "SELECTION_REMOVE":
if (action.ids.length === 0) return state;
action.ids.forEach((id) => ids.delete(id));
return { ...state, ids: new Set(ids) };
case "SELECTION_SET":
return {
...state,
ids: action.ids,
mostRecent: action.mostRecent,
anchor: action.anchor,
};
case "SELECTION_MOST_RECENT":
return { ...state, mostRecent: action.id };
case "SELECTION_ANCHOR":
return { ...state, anchor: action.id };
default:
return state;
}
}
import { ActionTypes, IdObj } from "../types/utils";
import { identify } from "../utils";
import { initialState } from "./initial";
/* Types */
export type SelectionState = {
ids: Set<string>;
anchor: string | null;
mostRecent: string | null;
};
/* Actions */
export const actions = {
clear: () => ({ type: "SELECTION_CLEAR" as const }),
only: (id: string | IdObj) => ({
type: "SELECTION_ONLY" as const,
id: identify(id),
}),
add: (id: string | string[] | IdObj | IdObj[]) => ({
type: "SELECTION_ADD" as const,
ids: (Array.isArray(id) ? id : [id]).map(identify),
}),
remove: (id: string | string[] | IdObj | IdObj[]) => ({
type: "SELECTION_REMOVE" as const,
ids: (Array.isArray(id) ? id : [id]).map(identify),
}),
set: (args: {
ids: Set<string>;
anchor: string | null;
mostRecent: string | null;
}) => ({
type: "SELECTION_SET" as const,
...args,
}),
mostRecent: (id: string | null | IdObj) => ({
type: "SELECTION_MOST_RECENT" as const,
id: id === null ? null : identify(id),
}),
anchor: (id: string | null | IdObj) => ({
type: "SELECTION_ANCHOR" as const,
id: id === null ? null : identify(id),
}),
};
/* Reducer */
export function reducer(
state: SelectionState = initialState()["nodes"]["selection"],
action: ActionTypes<typeof actions>
): SelectionState {
const ids = state.ids;
switch (action.type) {
case "SELECTION_CLEAR":
return { ...state, ids: new Set() };
case "SELECTION_ONLY":
return { ...state, ids: new Set([action.id]) };
case "SELECTION_ADD":
if (action.ids.length === 0) return state;
action.ids.forEach((id) => ids.add(id));
return { ...state, ids: new Set(ids) };
case "SELECTION_REMOVE":
if (action.ids.length === 0) return state;
action.ids.forEach((id) => ids.delete(id));
return { ...state, ids: new Set(ids) };
case "SELECTION_SET":
return {
...state,
ids: action.ids,
mostRecent: action.mostRecent,
anchor: action.anchor,
};
case "SELECTION_MOST_RECENT":
return { ...state, mostRecent: action.id };
case "SELECTION_ANCHOR":
return { ...state, anchor: action.id };
default:
return state;
}
}

View File

@ -1,9 +1,9 @@
export type CursorLocation = {
index: number | null;
level: number | null;
parentId: string | null;
};
export type DragItem = {
id: string;
};
export type CursorLocation = {
index: number | null;
level: number | null;
parentId: string | null;
};
export type DragItem = {
id: string;
};

View File

@ -1,32 +1,32 @@
import { NodeApi } from "../interfaces/node-api";
import { IdObj } from "./utils";
export type CreateHandler<T> = (args: {
parentId: string | null;
parentNode: NodeApi<T> | null;
index: number;
type: "internal" | "leaf";
}) => (IdObj | null) | Promise<IdObj | null>;
export type MoveHandler<T> = (args: {
dragIds: string[];
dragNodes: NodeApi<T>[];
parentId: string | null;
parentNode: NodeApi<T> | null;
index: number;
}) => void | Promise<void>;
export type RenameHandler<T> = (args: {
id: string;
name: string;
node: NodeApi<T>;
}) => void | Promise<void>;
export type DeleteHandler<T> = (args: {
ids: string[];
nodes: NodeApi<T>[];
}) => void | Promise<void>;
export type EditResult =
| { cancelled: true }
| { cancelled: false; value: string };
import { NodeApi } from "../interfaces/node-api";
import { IdObj } from "./utils";
export type CreateHandler<T> = (args: {
parentId: string | null;
parentNode: NodeApi<T> | null;
index: number;
type: "internal" | "leaf";
}) => (IdObj | null) | Promise<IdObj | null>;
export type MoveHandler<T> = (args: {
dragIds: string[];
dragNodes: NodeApi<T>[];
parentId: string | null;
parentNode: NodeApi<T> | null;
index: number;
}) => void | Promise<void>;
export type RenameHandler<T> = (args: {
id: string;
name: string;
node: NodeApi<T>;
}) => void | Promise<void>;
export type DeleteHandler<T> = (args: {
ids: string[];
nodes: NodeApi<T>[];
}) => void | Promise<void>;
export type EditResult =
| { cancelled: true }
| { cancelled: false; value: string };

View File

@ -1,34 +1,34 @@
import { CSSProperties, HTMLAttributes, ReactElement } from "react";
import { IdObj } from "./utils";
import { NodeApi } from "../interfaces/node-api";
import { TreeApi } from "../interfaces/tree-api";
import { XYCoord } from "react-dnd";
export type NodeRendererProps<T> = {
style: CSSProperties;
node: NodeApi<T>;
tree: TreeApi<T>;
dragHandle?: (el: HTMLDivElement | null) => void;
preview?: boolean;
};
export type RowRendererProps<T> = {
node: NodeApi<T>;
innerRef: (el: HTMLDivElement | null) => void;
attrs: HTMLAttributes<any>;
children: ReactElement;
};
export type DragPreviewProps = {
offset: XYCoord | null;
mouse: XYCoord | null;
id: string | null;
dragIds: string[];
isDragging: boolean;
};
export type CursorProps = {
top: number;
left: number;
indent: number;
};
import { CSSProperties, HTMLAttributes, ReactElement } from "react";
import { IdObj } from "./utils";
import { NodeApi } from "../interfaces/node-api";
import { TreeApi } from "../interfaces/tree-api";
import { XYCoord } from "react-dnd";
export type NodeRendererProps<T> = {
style: CSSProperties;
node: NodeApi<T>;
tree: TreeApi<T>;
dragHandle?: (el: HTMLDivElement | null) => void;
preview?: boolean;
};
export type RowRendererProps<T> = {
node: NodeApi<T>;
innerRef: (el: HTMLDivElement | null) => void;
attrs: HTMLAttributes<any>;
children: ReactElement;
};
export type DragPreviewProps = {
offset: XYCoord | null;
mouse: XYCoord | null;
id: string | null;
dragIds: string[];
isDragging: boolean;
};
export type CursorProps = {
top: number;
left: number;
indent: number;
};

View File

@ -1,3 +1,3 @@
import { NodeApi } from "../interfaces/node-api";
export type NodeState = typeof NodeApi.prototype["state"];
import { NodeApi } from "../interfaces/node-api";
export type NodeState = typeof NodeApi.prototype["state"];

View File

@ -1,80 +1,80 @@
import { BoolFunc } from "./utils";
import * as handlers from "./handlers";
import * as renderers from "./renderers";
import { ElementType, MouseEventHandler } from "react";
import { ListOnScrollProps } from "react-window";
import { NodeApi } from "../interfaces/node-api";
import { OpenMap } from "../state/open-slice";
import { useDragDropManager } from "react-dnd";
export interface TreeProps<T> {
/* Data Options */
data?: readonly T[];
initialData?: readonly T[];
/* Data Handlers */
onCreate?: handlers.CreateHandler<T>;
onMove?: handlers.MoveHandler<T>;
onRename?: handlers.RenameHandler<T>;
onDelete?: handlers.DeleteHandler<T>;
/* Renderers*/
children?: ElementType<renderers.NodeRendererProps<T>>;
renderRow?: ElementType<renderers.RowRendererProps<T>>;
renderDragPreview?: ElementType<renderers.DragPreviewProps>;
renderCursor?: ElementType<renderers.CursorProps>;
renderContainer?: ElementType<{}>;
/* Sizes */
rowHeight?: number;
overscanCount?: number;
width?: number | string;
height?: number;
indent?: number;
paddingTop?: number;
paddingBottom?: number;
padding?: number;
/* Config */
childrenAccessor?: string | ((d: T) => readonly T[] | null);
idAccessor?: string | ((d: T) => string);
openByDefault?: boolean;
selectionFollowsFocus?: boolean;
disableMultiSelection?: boolean;
disableEdit?: string | boolean | BoolFunc<T>;
disableDrag?: string | boolean | BoolFunc<T>;
disableDrop?:
| string
| boolean
| ((args: {
parentNode: NodeApi<T>;
dragNodes: NodeApi<T>[];
index: number;
}) => boolean);
/* Event Handlers */
onActivate?: (node: NodeApi<T>) => void;
onSelect?: (nodes: NodeApi<T>[]) => void;
onScroll?: (props: ListOnScrollProps) => void;
onToggle?: (id: string) => void;
onFocus?: (node: NodeApi<T>) => void;
/* Selection */
selection?: string;
/* Open State */
initialOpenState?: OpenMap;
/* Search */
searchTerm?: string;
searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
/* Extra */
className?: string | undefined;
rowClassName?: string | undefined;
dndRootElement?: globalThis.Node | null;
onClick?: MouseEventHandler;
onContextMenu?: MouseEventHandler;
dndManager?: ReturnType<typeof useDragDropManager>;
}
import { BoolFunc } from "./utils";
import * as handlers from "./handlers";
import * as renderers from "./renderers";
import { ElementType, MouseEventHandler } from "react";
import { ListOnScrollProps } from "react-window";
import { NodeApi } from "../interfaces/node-api";
import { OpenMap } from "../state/open-slice";
import { useDragDropManager } from "react-dnd";
export interface TreeProps<T> {
/* Data Options */
data?: readonly T[];
initialData?: readonly T[];
/* Data Handlers */
onCreate?: handlers.CreateHandler<T>;
onMove?: handlers.MoveHandler<T>;
onRename?: handlers.RenameHandler<T>;
onDelete?: handlers.DeleteHandler<T>;
/* Renderers*/
children?: ElementType<renderers.NodeRendererProps<T>>;
renderRow?: ElementType<renderers.RowRendererProps<T>>;
renderDragPreview?: ElementType<renderers.DragPreviewProps>;
renderCursor?: ElementType<renderers.CursorProps>;
renderContainer?: ElementType<{}>;
/* Sizes */
rowHeight?: number;
overscanCount?: number;
width?: number | string;
height?: number;
indent?: number;
paddingTop?: number;
paddingBottom?: number;
padding?: number;
/* Config */
childrenAccessor?: string | ((d: T) => readonly T[] | null);
idAccessor?: string | ((d: T) => string);
openByDefault?: boolean;
selectionFollowsFocus?: boolean;
disableMultiSelection?: boolean;
disableEdit?: string | boolean | BoolFunc<T>;
disableDrag?: string | boolean | BoolFunc<T>;
disableDrop?:
| string
| boolean
| ((args: {
parentNode: NodeApi<T>;
dragNodes: NodeApi<T>[];
index: number;
}) => boolean);
/* Event Handlers */
onActivate?: (node: NodeApi<T>) => void;
onSelect?: (nodes: NodeApi<T>[]) => void;
onScroll?: (props: ListOnScrollProps) => void;
onToggle?: (id: string) => void;
onFocus?: (node: NodeApi<T>) => void;
/* Selection */
selection?: string;
/* Open State */
initialOpenState?: OpenMap;
/* Search */
searchTerm?: string;
searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
/* Extra */
className?: string | undefined;
rowClassName?: string | undefined;
dndRootElement?: globalThis.Node | null;
onClick?: MouseEventHandler;
onContextMenu?: MouseEventHandler;
dndManager?: ReturnType<typeof useDragDropManager>;
}

View File

@ -1,18 +1,18 @@
import { AnyAction } from "redux";
import { NodeApi } from "../interfaces/node-api";
export interface IdObj {
id: string;
}
export type Identity = string | IdObj | null;
export type BoolFunc<T> = (data: T) => boolean;
export type ActionTypes<
Actions extends { [name: string]: (...args: any[]) => AnyAction }
> = ReturnType<Actions[keyof Actions]>;
export type SelectOptions = { multi?: boolean; contiguous?: boolean };
export type NodesById<T> = { [id: string]: NodeApi<T> };
import { AnyAction } from "redux";
import { NodeApi } from "../interfaces/node-api";
export interface IdObj {
id: string;
}
export type Identity = string | IdObj | null;
export type BoolFunc<T> = (data: T) => boolean;
export type ActionTypes<
Actions extends { [name: string]: (...args: any[]) => AnyAction }
> = ReturnType<Actions[keyof Actions]>;
export type SelectOptions = { multi?: boolean; contiguous?: boolean };
export type NodesById<T> = { [id: string]: NodeApi<T> };

View File

@ -1,182 +1,182 @@
import { NodeApi } from "./interfaces/node-api";
import { TreeApi } from "./interfaces/tree-api";
import { IdObj } from "./types/utils";
export function bound(n: number, min: number, max: number) {
return Math.max(Math.min(n, max), min);
}
export function isItem(node: NodeApi<any> | null) {
return node && node.isLeaf;
}
export function isClosed(node: NodeApi<any> | null) {
return node && node.isInternal && !node.isOpen;
}
export function isOpenWithEmptyChildren(node: NodeApi<any> | null) {
return node && node.isOpen && !node.children?.length;
}
/**
* Is first param a descendant of the second param
*/
export const isDescendant = (a: NodeApi<any>, b: NodeApi<any>) => {
let n: NodeApi<any> | null = a;
while (n) {
if (n.id === b.id) return true;
n = n.parent;
}
return false;
};
export const indexOf = (node: NodeApi<any>) => {
if (!node.parent) throw Error("Node does not have a parent");
return node.parent.children!.findIndex((c) => c.id === node.id);
};
export function noop() {}
export function dfs(node: NodeApi<any>, id: string): NodeApi<any> | null {
if (!node) return null;
if (node.id === id) return node;
if (node.children) {
for (let child of node.children) {
const result = dfs(child, id);
if (result) return result;
}
}
return null;
}
export function walk(
node: NodeApi<any>,
fn: (node: NodeApi<any>) => void
): void {
fn(node);
if (node.children) {
for (let child of node.children) {
walk(child, fn);
}
}
}
export function focusNextElement(target: HTMLElement) {
const elements = getFocusable(target);
let next: HTMLElement;
for (let i = 0; i < elements.length; ++i) {
const item = elements[i];
if (item === target) {
next = nextItem(elements, i);
break;
}
}
// @ts-ignore ??
next?.focus();
}
export function focusPrevElement(target: HTMLElement) {
const elements = getFocusable(target);
let next: HTMLElement;
for (let i = 0; i < elements.length; ++i) {
const item = elements[i];
if (item === target) {
next = prevItem(elements, i);
break;
}
}
// @ts-ignore
next?.focus();
}
function nextItem(list: HTMLElement[], index: number) {
if (index + 1 < list.length) {
return list[index + 1] as HTMLElement;
} else {
return list[0] as HTMLElement;
}
}
function prevItem(list: HTMLElement[], index: number) {
if (index - 1 >= 0) {
return list[index - 1];
} else {
return list[list.length - 1];
}
}
function getFocusable(target: HTMLElement) {
return Array.from(
document.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)'
)
).filter((e) => e === target || !target.contains(e)) as HTMLElement[];
}
export function access<T = boolean>(
obj: any,
accessor: string | boolean | Function
): T {
if (typeof accessor === "boolean") return accessor as unknown as T;
if (typeof accessor === "string") return obj[accessor] as T;
return accessor(obj) as T;
}
export function identifyNull(obj: string | IdObj | null) {
if (obj === null) return null;
else return identify(obj);
}
export function identify(obj: string | IdObj) {
return typeof obj === "string" ? obj : obj.id;
}
export function mergeRefs(...refs: any) {
return (instance: any) => {
refs.forEach((ref: any) => {
if (typeof ref === "function") {
ref(instance);
} else if (ref != null) {
ref.current = instance;
}
});
};
}
export function safeRun<T extends (...args: any[]) => any>(
fn: T | undefined,
...args: Parameters<T>
) {
if (fn) return fn(...args);
}
export function waitFor(fn: () => boolean) {
return new Promise<void>((resolve, reject) => {
let tries = 0;
function check() {
tries += 1;
if (tries === 100) reject();
if (fn()) resolve();
else setTimeout(check, 10);
}
check();
});
}
export function getInsertIndex(tree: TreeApi<any>) {
const focus = tree.focusedNode;
if (!focus) return tree.root.children?.length ?? 0;
if (focus.isOpen) return 0;
if (focus.parent) return focus.childIndex + 1;
return 0;
}
export function getInsertParentId(tree: TreeApi<any>) {
const focus = tree.focusedNode;
if (!focus) return null;
if (focus.isOpen) return focus.id;
if (focus.parent && !focus.parent.isRoot) return focus.parent.id;
return null;
}
import { NodeApi } from "./interfaces/node-api";
import { TreeApi } from "./interfaces/tree-api";
import { IdObj } from "./types/utils";
export function bound(n: number, min: number, max: number) {
return Math.max(Math.min(n, max), min);
}
export function isItem(node: NodeApi<any> | null) {
return node && node.isLeaf;
}
export function isClosed(node: NodeApi<any> | null) {
return node && node.isInternal && !node.isOpen;
}
export function isOpenWithEmptyChildren(node: NodeApi<any> | null) {
return node && node.isOpen && !node.children?.length;
}
/**
* Is first param a descendant of the second param
*/
export const isDescendant = (a: NodeApi<any>, b: NodeApi<any>) => {
let n: NodeApi<any> | null = a;
while (n) {
if (n.id === b.id) return true;
n = n.parent;
}
return false;
};
export const indexOf = (node: NodeApi<any>) => {
if (!node.parent) throw Error("Node does not have a parent");
return node.parent.children!.findIndex((c) => c.id === node.id);
};
export function noop() {}
export function dfs(node: NodeApi<any>, id: string): NodeApi<any> | null {
if (!node) return null;
if (node.id === id) return node;
if (node.children) {
for (let child of node.children) {
const result = dfs(child, id);
if (result) return result;
}
}
return null;
}
export function walk(
node: NodeApi<any>,
fn: (node: NodeApi<any>) => void
): void {
fn(node);
if (node.children) {
for (let child of node.children) {
walk(child, fn);
}
}
}
export function focusNextElement(target: HTMLElement) {
const elements = getFocusable(target);
let next: HTMLElement;
for (let i = 0; i < elements.length; ++i) {
const item = elements[i];
if (item === target) {
next = nextItem(elements, i);
break;
}
}
// @ts-ignore ??
next?.focus();
}
export function focusPrevElement(target: HTMLElement) {
const elements = getFocusable(target);
let next: HTMLElement;
for (let i = 0; i < elements.length; ++i) {
const item = elements[i];
if (item === target) {
next = prevItem(elements, i);
break;
}
}
// @ts-ignore
next?.focus();
}
function nextItem(list: HTMLElement[], index: number) {
if (index + 1 < list.length) {
return list[index + 1] as HTMLElement;
} else {
return list[0] as HTMLElement;
}
}
function prevItem(list: HTMLElement[], index: number) {
if (index - 1 >= 0) {
return list[index - 1];
} else {
return list[list.length - 1];
}
}
function getFocusable(target: HTMLElement) {
return Array.from(
document.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled]), details:not([disabled]), summary:not(:disabled)'
)
).filter((e) => e === target || !target.contains(e)) as HTMLElement[];
}
export function access<T = boolean>(
obj: any,
accessor: string | boolean | Function
): T {
if (typeof accessor === "boolean") return accessor as unknown as T;
if (typeof accessor === "string") return obj[accessor] as T;
return accessor(obj) as T;
}
export function identifyNull(obj: string | IdObj | null) {
if (obj === null) return null;
else return identify(obj);
}
export function identify(obj: string | IdObj) {
return typeof obj === "string" ? obj : obj.id;
}
export function mergeRefs(...refs: any) {
return (instance: any) => {
refs.forEach((ref: any) => {
if (typeof ref === "function") {
ref(instance);
} else if (ref != null) {
ref.current = instance;
}
});
};
}
export function safeRun<T extends (...args: any[]) => any>(
fn: T | undefined,
...args: Parameters<T>
) {
if (fn) return fn(...args);
}
export function waitFor(fn: () => boolean) {
return new Promise<void>((resolve, reject) => {
let tries = 0;
function check() {
tries += 1;
if (tries === 100) reject();
if (fn()) resolve();
else setTimeout(check, 10);
}
check();
});
}
export function getInsertIndex(tree: TreeApi<any>) {
const focus = tree.focusedNode;
if (!focus) return tree.root.children?.length ?? 0;
if (focus.isOpen) return 0;
if (focus.parent) return focus.childIndex + 1;
return 0;
}
export function getInsertParentId(tree: TreeApi<any>) {
const focus = tree.focusedNode;
if (!focus) return null;
if (focus.isOpen) return focus.id;
if (focus.parent && !focus.parent.isRoot) return focus.parent.id;
return null;
}

View File

@ -1,52 +1,52 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -1,104 +1,104 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -1,43 +1,43 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@ -1,5 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@ -1,38 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -1,34 +1,34 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => {
return (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
)
Badge.displayName = "Badge"
export { Badge, badgeVariants };
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => {
return (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
)
Badge.displayName = "Badge"
export { Badge, badgeVariants };

View File

@ -1,90 +1,90 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -1,47 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-2",
lg: "h-11 rounded-md px-4",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-2",
lg: "h-11 rounded-md px-4",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -1,54 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@ -1,43 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg dark:bg-slate-800/50 text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg dark:bg-slate-800/50 text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -1,224 +1,224 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

View File

@ -1,303 +1,303 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@ -1,26 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -1,164 +1,164 @@
import React, { useState, type ReactNode, useEffect } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
interface CollapsibleSectionProps {
title: ReactNode;
children: ReactNode;
initiallyOpen?: boolean;
storageKey?: string; // New prop for localStorage key
className?: string;
headerClassName?: string;
headerContent?: ReactNode;
titleClassName?: string;
buttonClassName?: string;
contentClassName?: string;
asCard?: boolean; // New prop to decide if it should render as a Card
onStateChange?: (isOpen: boolean) => void; // New prop for state change callback
id?: string;
minimal?: boolean; // New prop for minimal styling
renderHeader?: (
toggle: () => void,
isOpen: boolean
) => React.ReactNode;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
initiallyOpen = true,
storageKey,
className = '',
headerClassName,
headerContent,
titleClassName,
buttonClassName = '', // Made button smaller
contentClassName,
asCard = false, // Default to not rendering as a card
onStateChange, // Destructure new prop
id,
minimal = false,
renderHeader
}) => {
const [isOpen, setIsOpen] = useState(() => {
if (storageKey) {
try {
const storedState = localStorage.getItem(storageKey);
if (storedState !== null) {
return JSON.parse(storedState) as boolean;
}
} catch (error) {
console.error(`Error reading CollapsibleSection state from localStorage for key "${storageKey}":`, error);
}
}
return initiallyOpen;
});
useEffect(() => {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(isOpen));
if (onStateChange) { // Call onStateChange when state is synced from localStorage
onStateChange(isOpen);
}
} catch (error) {
console.error(`Error writing CollapsibleSection state to localStorage for key "${storageKey}":`, error);
}
}
}, [isOpen, storageKey, onStateChange]);
const toggleOpen = () => {
const newState = !isOpen;
setIsOpen(newState);
if (onStateChange) { // Call onStateChange when toggling
onStateChange(newState);
}
};
// Apply minimal styling if enabled
const finalHeaderClassName = headerClassName || (minimal
? 'flex justify-between items-center px-3 py-2 cursor-pointer border-b border-border'
: 'flex justify-between items-center p-3 md:p-4 cursor-pointer border-b border-border'
);
const finalTitleClassName = titleClassName || (minimal
? 'text-sm font-semibold'
: 'text-md md:text-lg font-semibold'
);
const finalContentClassName = contentClassName || (minimal
? 'p-3'
: 'p-3 md:p-4'
);
const finalContainerClassName = minimal
? `rounded-lg border border-border bg-card ${className}`
: `rounded-lg shadow-none md:shadow-md border border-border bg-card ${className}`;
const chevronSize = minimal ? "h-4 w-4" : "h-5 w-5";
const header = renderHeader ? (
renderHeader(toggleOpen, isOpen)
) : (
<div className={finalHeaderClassName} onClick={toggleOpen}>
<div className={finalTitleClassName}>{title}</div>
<div className="flex items-center gap-2">
{headerContent}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
toggleOpen();
}}
className={`${buttonClassName} ${minimal ? 'h-6 w-6' : 'h-8 w-8'}`}
>
{isOpen ? (
<ChevronUp className={chevronSize} />
) : (
<ChevronDown className={chevronSize} />
)}
</Button>
</div>
</div>
);
if (asCard) {
const cardClassName = minimal
? `${className}`
: `shadow-none md:shadow-md ${className}`;
return (
<Card className={cardClassName} id={id}>
<CardHeader className={`cursor-pointer ${finalHeaderClassName}`} onClick={toggleOpen}>
<div className="flex justify-between items-center w-full">
<div className={finalTitleClassName}>{title}</div>
<div className="flex items-center gap-2">
{headerContent}
<Button
variant="ghost"
size="icon"
onClick={(e) => { e.stopPropagation(); toggleOpen(); }}
className={`${buttonClassName} ${minimal ? 'h-6 w-6' : 'h-8 w-8'}`}
>
{isOpen ? <ChevronUp className={chevronSize} /> : <ChevronDown className={chevronSize} />}
</Button>
</div>
</div>
</CardHeader>
{isOpen && <CardContent className={finalContentClassName}>{children}</CardContent>}
</Card>
);
}
return (
<div className={finalContainerClassName} id={id}>
{header}
{isOpen && <div className={finalContentClassName}>{children}</div>}
</div>
);
};
export default CollapsibleSection;
import React, { useState, type ReactNode, useEffect } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
interface CollapsibleSectionProps {
title: ReactNode;
children: ReactNode;
initiallyOpen?: boolean;
storageKey?: string; // New prop for localStorage key
className?: string;
headerClassName?: string;
headerContent?: ReactNode;
titleClassName?: string;
buttonClassName?: string;
contentClassName?: string;
asCard?: boolean; // New prop to decide if it should render as a Card
onStateChange?: (isOpen: boolean) => void; // New prop for state change callback
id?: string;
minimal?: boolean; // New prop for minimal styling
renderHeader?: (
toggle: () => void,
isOpen: boolean
) => React.ReactNode;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
initiallyOpen = true,
storageKey,
className = '',
headerClassName,
headerContent,
titleClassName,
buttonClassName = '', // Made button smaller
contentClassName,
asCard = false, // Default to not rendering as a card
onStateChange, // Destructure new prop
id,
minimal = false,
renderHeader
}) => {
const [isOpen, setIsOpen] = useState(() => {
if (storageKey) {
try {
const storedState = localStorage.getItem(storageKey);
if (storedState !== null) {
return JSON.parse(storedState) as boolean;
}
} catch (error) {
console.error(`Error reading CollapsibleSection state from localStorage for key "${storageKey}":`, error);
}
}
return initiallyOpen;
});
useEffect(() => {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(isOpen));
if (onStateChange) { // Call onStateChange when state is synced from localStorage
onStateChange(isOpen);
}
} catch (error) {
console.error(`Error writing CollapsibleSection state to localStorage for key "${storageKey}":`, error);
}
}
}, [isOpen, storageKey, onStateChange]);
const toggleOpen = () => {
const newState = !isOpen;
setIsOpen(newState);
if (onStateChange) { // Call onStateChange when toggling
onStateChange(newState);
}
};
// Apply minimal styling if enabled
const finalHeaderClassName = headerClassName || (minimal
? 'flex justify-between items-center px-3 py-2 cursor-pointer border-b border-border'
: 'flex justify-between items-center p-3 md:p-4 cursor-pointer border-b border-border'
);
const finalTitleClassName = titleClassName || (minimal
? 'text-sm font-semibold'
: 'text-md md:text-lg font-semibold'
);
const finalContentClassName = contentClassName || (minimal
? 'p-3'
: 'p-3 md:p-4'
);
const finalContainerClassName = minimal
? `rounded-lg border border-border bg-card ${className}`
: `rounded-lg shadow-none md:shadow-md border border-border bg-card ${className}`;
const chevronSize = minimal ? "h-4 w-4" : "h-5 w-5";
const header = renderHeader ? (
renderHeader(toggleOpen, isOpen)
) : (
<div className={finalHeaderClassName} onClick={toggleOpen}>
<div className={finalTitleClassName}>{title}</div>
<div className="flex items-center gap-2">
{headerContent}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
toggleOpen();
}}
className={`${buttonClassName} ${minimal ? 'h-6 w-6' : 'h-8 w-8'}`}
>
{isOpen ? (
<ChevronUp className={chevronSize} />
) : (
<ChevronDown className={chevronSize} />
)}
</Button>
</div>
</div>
);
if (asCard) {
const cardClassName = minimal
? `${className}`
: `shadow-none md:shadow-md ${className}`;
return (
<Card className={cardClassName} id={id}>
<CardHeader className={`cursor-pointer ${finalHeaderClassName}`} onClick={toggleOpen}>
<div className="flex justify-between items-center w-full">
<div className={finalTitleClassName}>{title}</div>
<div className="flex items-center gap-2">
{headerContent}
<Button
variant="ghost"
size="icon"
onClick={(e) => { e.stopPropagation(); toggleOpen(); }}
className={`${buttonClassName} ${minimal ? 'h-6 w-6' : 'h-8 w-8'}`}
>
{isOpen ? <ChevronUp className={chevronSize} /> : <ChevronDown className={chevronSize} />}
</Button>
</div>
</div>
</CardHeader>
{isOpen && <CardContent className={finalContentClassName}>{children}</CardContent>}
</Card>
);
}
return (
<div className={finalContainerClassName} id={id}>
{header}
{isOpen && <div className={finalContentClassName}>{children}</div>}
</div>
);
};
export default CollapsibleSection;

View File

@ -1,9 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -1,132 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -1,178 +1,178 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@ -1,95 +1,95 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -1,87 +1,87 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -1,179 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -1,129 +1,129 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };

View File

@ -1,27 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -1,61 +1,61 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -1,22 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -1,17 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

Some files were not shown because too many files have changed in this diff Show More