mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
3 files changed
+116
-46
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
@@ -11,6 +11,8 @@ import { SiFacebook, SiInstagram } from "react-icons/si";
|
|||||||
export default function MediaUpload() {
|
export default function MediaUpload() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(5);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: mediaList } = useQuery({
|
const { data: mediaList } = useQuery({
|
||||||
queryKey: ["/api/media"],
|
queryKey: ["/api/media"],
|
||||||
@@ -69,6 +71,28 @@ export default function MediaUpload() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scroll infini - IntersectionObserver
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && mediaList && visibleCount < (mediaList as any[]).length) {
|
||||||
|
setVisibleCount(prev => Math.min(prev + 5, (mediaList as any[]).length));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loadMoreRef.current) {
|
||||||
|
observer.observe(loadMoreRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (loadMoreRef.current) {
|
||||||
|
observer.unobserve(loadMoreRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mediaList, visibleCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
||||||
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
<div className="border-b border-border/50 p-6 bg-gradient-to-r from-primary/5 to-secondary/5">
|
||||||
@@ -123,8 +147,8 @@ export default function MediaUpload() {
|
|||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||||
{(mediaList as any[])?.slice(0, 5).map((media: any) => (
|
{(mediaList as any[])?.slice(0, visibleCount).map((media: any) => (
|
||||||
<div
|
<div
|
||||||
key={media.id}
|
key={media.id}
|
||||||
onClick={() => setSelectedFile(media)}
|
onClick={() => setSelectedFile(media)}
|
||||||
@@ -165,6 +189,13 @@ export default function MediaUpload() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Élément sentinelle pour le scroll infini */}
|
||||||
|
{mediaList && visibleCount < (mediaList as any[]).length && (
|
||||||
|
<div ref={loadMoreRef} className="flex justify-center py-4">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
|
import { Send, Sparkles, Image as ImageIcon, Calendar, Upload, Camera, GripVertical, Loader2 } from "lucide-react";
|
||||||
@@ -112,6 +112,8 @@ export default function NewPost() {
|
|||||||
const [postText, setPostText] = useState('');
|
const [postText, setPostText] = useState('');
|
||||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
|
const [visibleMediaCount, setVisibleMediaCount] = useState(12);
|
||||||
|
const loadMoreMediaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
@@ -310,6 +312,28 @@ export default function NewPost() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scroll infini - IntersectionObserver pour la grille de médias
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && mediaList && visibleMediaCount < mediaList.length) {
|
||||||
|
setVisibleMediaCount(prev => Math.min(prev + 12, mediaList.length));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loadMoreMediaRef.current) {
|
||||||
|
observer.observe(loadMoreMediaRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (loadMoreMediaRef.current) {
|
||||||
|
observer.unobserve(loadMoreMediaRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mediaList, visibleMediaCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
@@ -451,48 +475,57 @@ export default function NewPost() {
|
|||||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
Toutes les photos
|
Toutes les photos
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="max-h-[500px] overflow-y-auto">
|
||||||
{mediaList.map((media) => {
|
<div className="grid grid-cols-3 gap-2">
|
||||||
const isSelected = selectedMedia.includes(media.id);
|
{mediaList.slice(0, visibleMediaCount).map((media) => {
|
||||||
|
const isSelected = selectedMedia.includes(media.id);
|
||||||
return (
|
|
||||||
<button
|
return (
|
||||||
key={media.id}
|
<button
|
||||||
onClick={() => {
|
key={media.id}
|
||||||
if (isSelected) {
|
onClick={() => {
|
||||||
// Remove from selection
|
if (isSelected) {
|
||||||
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
// Remove from selection
|
||||||
} else if (selectedMedia.length < 10) {
|
setSelectedMedia(prev => prev.filter(id => id !== media.id));
|
||||||
// Add to selection (max 10)
|
} else if (selectedMedia.length < 10) {
|
||||||
setSelectedMedia(prev => [...prev, media.id]);
|
// Add to selection (max 10)
|
||||||
} else {
|
setSelectedMedia(prev => [...prev, media.id]);
|
||||||
toast({
|
} else {
|
||||||
title: "Limite atteinte",
|
toast({
|
||||||
description: "Maximum 10 photos par publication",
|
title: "Limite atteinte",
|
||||||
variant: "destructive",
|
description: "Maximum 10 photos par publication",
|
||||||
});
|
variant: "destructive",
|
||||||
}
|
});
|
||||||
}}
|
}
|
||||||
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
}}
|
||||||
isSelected
|
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
||||||
? 'border-primary ring-2 ring-primary opacity-50'
|
isSelected
|
||||||
: 'border-transparent hover:border-muted-foreground'
|
? 'border-primary ring-2 ring-primary opacity-50'
|
||||||
}`}
|
: 'border-transparent hover:border-muted-foreground'
|
||||||
data-testid={`button-select-media-${media.id}`}
|
}`}
|
||||||
>
|
data-testid={`button-select-media-${media.id}`}
|
||||||
<img
|
>
|
||||||
src={media.originalUrl}
|
<img
|
||||||
alt={media.fileName}
|
src={media.originalUrl}
|
||||||
className="w-full h-full object-cover"
|
alt={media.fileName}
|
||||||
/>
|
className="w-full h-full object-cover"
|
||||||
{isSelected && (
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
|
{isSelected && (
|
||||||
<div className="text-xs font-semibold text-primary">Sélectionnée</div>
|
<div className="absolute inset-0 bg-primary/10 flex items-center justify-center">
|
||||||
</div>
|
<div className="text-xs font-semibold text-primary">Sélectionnée</div>
|
||||||
)}
|
</div>
|
||||||
</button>
|
)}
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Élément sentinelle pour le scroll infini */}
|
||||||
|
{mediaList && visibleMediaCount < mediaList.length && (
|
||||||
|
<div ref={loadMoreMediaRef} className="flex justify-center py-4 col-span-3">
|
||||||
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ Authentication uses **Passport.js** with local strategy and `bcrypt` for passwor
|
|||||||
|
|
||||||
The UI is built with **Radix UI** and **shadcn/ui**, leveraging **Tailwind CSS** for styling. The default theme is dark mode. Key design decisions include a component-based architecture for reusability, custom path aliases, and a responsive, mobile-first approach. The platform supports multi-photo carousel posts with drag-and-drop reordering, and a **PreviewModal** offers realistic rendering of posts across Facebook and Instagram formats before publishing. The calendar view is responsive, with a grid layout for desktop and an expandable list view for mobile, allowing editing and deletion of scheduled posts directly from the calendar.
|
The UI is built with **Radix UI** and **shadcn/ui**, leveraging **Tailwind CSS** for styling. The default theme is dark mode. Key design decisions include a component-based architecture for reusability, custom path aliases, and a responsive, mobile-first approach. The platform supports multi-photo carousel posts with drag-and-drop reordering, and a **PreviewModal** offers realistic rendering of posts across Facebook and Instagram formats before publishing. The calendar view is responsive, with a grid layout for desktop and an expandable list view for mobile, allowing editing and deletion of scheduled posts directly from the calendar.
|
||||||
|
|
||||||
|
**Infinite Scroll**: Both the media library and new post pages implement infinite scroll using IntersectionObserver for performance optimization:
|
||||||
|
- **Media Library**: Loads 5 media items initially, then 5 more when scrolling to bottom (max height: 500px)
|
||||||
|
- **New Post Page**: Loads 12 media items initially in a 3-column grid, then 12 more when scrolling (max height: 500px)
|
||||||
|
- Visual loading indicator ("Chargement..." with spinner) appears when more content is available
|
||||||
|
- Proper cleanup to prevent memory leaks
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
|
|
||||||
### AI Content Generation
|
### AI Content Generation
|
||||||
|
|||||||
Reference in new issue
Block a user