v1 cleanup
This commit is contained in:
parent
d2843f6ee4
commit
46e25ccd48
@ -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
@ -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;
|
||||
|
||||
@ -169,4 +169,4 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleSection;
|
||||
export default CollapsibleSection;
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
@ -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
@ -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';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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><</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><</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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -72,4 +72,4 @@ const LogsPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsPage;
|
||||
export default LogsPage;
|
||||
|
||||
@ -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 }} />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}, {});
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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> };
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user