mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 09:41:32 +02:00
Implement upload from URL and enhanced reels interface
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
@@ -6,10 +6,37 @@ import { useEffect } from "react";
|
||||
export default function ReelPage() {
|
||||
useEffect(() => {
|
||||
document.title = `Reels | LUMINA`;
|
||||
// Prevent scrolling on this page for a full-screen experience
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Hide the header and bottom bar when on the reel page
|
||||
const topNav = document.querySelector('nav');
|
||||
const bottomBar = document.querySelector('.fixed.bottom-0');
|
||||
|
||||
if (topNav) {
|
||||
(topNav as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
if (bottomBar) {
|
||||
(bottomBar as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Restore scrolling and show navigation elements when leaving the page
|
||||
document.body.style.overflow = '';
|
||||
|
||||
if (topNav) {
|
||||
(topNav as HTMLElement).style.display = '';
|
||||
}
|
||||
|
||||
if (bottomBar) {
|
||||
(bottomBar as HTMLElement).style.display = '';
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="py-6 px-6">
|
||||
<div className="fixed inset-0 h-screen w-screen overflow-hidden z-50">
|
||||
<ReelFeed />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,460 @@
|
||||
import { useRef } from "react";
|
||||
import { useNostrEvents, dateToUnix } from "nostr-react";
|
||||
import NoteCard from './NoteCard';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNostrEvents, useNostr, dateToUnix } from "nostr-react";
|
||||
import { ChevronUp, ChevronDown, Heart, MessageCircle, Share2, User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nip19, Event as NostrEvent } from "nostr-tools";
|
||||
import { useProfile } from "nostr-react";
|
||||
import Link from "next/link";
|
||||
import { blacklistPubkeys, signEvent } from "@/utils/utils";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
// Define interface for NIP-71 video event
|
||||
interface VideoEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
title: string;
|
||||
description: string;
|
||||
videoUrl: string;
|
||||
imageUrl: string;
|
||||
duration?: number;
|
||||
dimensions?: { width: number; height: number };
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
const ReelFeed: React.FC = () => {
|
||||
const now = useRef(new Date()); // Make sure current time isn't re-rendered
|
||||
|
||||
const { events } = useNostrEvents({
|
||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||
const [isLiked, setIsLiked] = useState<Record<string, boolean>>({});
|
||||
const videoRefs = useRef<Record<string, HTMLVideoElement | null>>({});
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
const [videoEvents, setVideoEvents] = useState<VideoEvent[]>([]);
|
||||
const [loadMoreCounter, setLoadMoreCounter] = useState(1); // Counter to trigger loading more events
|
||||
const { publish } = useNostr();
|
||||
|
||||
// Fetch NIP-71 kind 22 (short video) events with increased limit
|
||||
const { events: rawEvents } = useNostrEvents({
|
||||
filter: {
|
||||
// since: dateToUnix(now.current), // all new events from now
|
||||
// since: 0,
|
||||
// limit: 100,
|
||||
kinds: [1063],
|
||||
kinds: [22], // NIP-71 short videos
|
||||
limit: 50 * loadMoreCounter, // Increase limit based on counter
|
||||
},
|
||||
});
|
||||
|
||||
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
|
||||
// filter events with regex that checks for png, jpg, or gif
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
|
||||
// Filter out events from blacklisted pubkeys
|
||||
const events = rawEvents?.filter(event => {
|
||||
const isBlacklisted = blacklistPubkeys.has(event.pubkey);
|
||||
return !isBlacklisted;
|
||||
}) || [];
|
||||
|
||||
// now filter all events with a tag[0] == t and tag[1] == nsfw
|
||||
// filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));
|
||||
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 't' && tag[1] == 'nsfw'}));
|
||||
// filter out all replies
|
||||
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));
|
||||
// Load more events if we don't have enough after filtering
|
||||
useEffect(() => {
|
||||
// Check if we have enough events after filtering
|
||||
if (events.length < 20 && rawEvents && rawEvents.length > 0 &&
|
||||
// Make sure we're not in an infinite loop by checking if we have more events to load
|
||||
rawEvents.length >= 50 * (loadMoreCounter - 1)) {
|
||||
// Only increase counter if we actually received events but need more
|
||||
setLoadMoreCounter(prev => prev + 1);
|
||||
}
|
||||
}, [events, rawEvents, loadMoreCounter]);
|
||||
|
||||
// Track reactions to update UI accordingly
|
||||
const { events: reactions } = useNostrEvents({
|
||||
filter: {
|
||||
kinds: [7], // Reaction events
|
||||
'#e': videoEvents.map(v => v.id),
|
||||
},
|
||||
});
|
||||
|
||||
// Update liked status based on fetched reactions
|
||||
useEffect(() => {
|
||||
if (!reactions) return;
|
||||
|
||||
// Check local storage for current user pubkey
|
||||
const storedPubkey = typeof window !== 'undefined' ? localStorage.getItem('pubkey') : null;
|
||||
if (!storedPubkey) return;
|
||||
|
||||
// Update liked status for each video
|
||||
const likedStatus: Record<string, boolean> = {};
|
||||
|
||||
reactions.forEach(reaction => {
|
||||
// Only count reactions from the current user
|
||||
if (reaction.pubkey === storedPubkey) {
|
||||
// Find the target event id
|
||||
const eventTag = reaction.tags.find(tag => tag[0] === 'e');
|
||||
if (eventTag && eventTag[1]) {
|
||||
likedStatus[eventTag[1]] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setIsLiked(likedStatus);
|
||||
}, [reactions]);
|
||||
|
||||
// Parse NIP-71 events
|
||||
useEffect(() => {
|
||||
if (!events || events.length === 0) return;
|
||||
|
||||
const parsedEvents: VideoEvent[] = events
|
||||
.map(event => {
|
||||
try {
|
||||
// Find title tag
|
||||
const titleTag = event.tags.find(tag => tag[0] === "title");
|
||||
const title = titleTag ? titleTag[1] : "Untitled Video";
|
||||
|
||||
// Find duration tag
|
||||
const durationTag = event.tags.find(tag => tag[0] === "duration");
|
||||
const duration = durationTag ? parseInt(durationTag[1]) : undefined;
|
||||
|
||||
// Extract video data from imeta tags
|
||||
const imetaTags = event.tags.filter(tag => tag[0] === "imeta");
|
||||
if (imetaTags.length === 0) return null;
|
||||
|
||||
// Find the first valid imeta tag with a video URL
|
||||
let videoUrl = "";
|
||||
let imageUrl = "";
|
||||
let dimensions = undefined;
|
||||
let mimeType = undefined;
|
||||
|
||||
for (const imeta of imetaTags) {
|
||||
// Parse dimension info
|
||||
const dimInfo = imeta.find(item => item.startsWith("dim "));
|
||||
if (dimInfo) {
|
||||
const [width, height] = dimInfo.replace("dim ", "").split("x").map(Number);
|
||||
dimensions = { width, height };
|
||||
}
|
||||
|
||||
// Parse mime type
|
||||
const mInfo = imeta.find(item => item.startsWith("m "));
|
||||
if (mInfo) {
|
||||
mimeType = mInfo.replace("m ", "");
|
||||
}
|
||||
|
||||
// Check if it's a video mime type
|
||||
if (mimeType && mimeType.startsWith("video/")) {
|
||||
// Get video URL
|
||||
const urlInfo = imeta.find(item => item.startsWith("url "));
|
||||
if (urlInfo) {
|
||||
videoUrl = urlInfo.replace("url ", "");
|
||||
}
|
||||
|
||||
// Get image preview URL
|
||||
const imageInfo = imeta.find(item => item.startsWith("image "));
|
||||
if (imageInfo) {
|
||||
imageUrl = imageInfo.replace("image ", "");
|
||||
}
|
||||
|
||||
if (videoUrl) break; // Found a valid video URL
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoUrl) return null; // Skip if no valid video URL found
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
title,
|
||||
description: event.content,
|
||||
videoUrl,
|
||||
imageUrl,
|
||||
duration,
|
||||
dimensions,
|
||||
mimeType
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error parsing video event:", error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as VideoEvent[]; // Filter out null values
|
||||
|
||||
setVideoEvents(parsedEvents);
|
||||
}, [events]);
|
||||
|
||||
// Touch handlers for swiping
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchStart(e.targetTouches[0].clientY);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientY);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const isUpSwipe = distance > 50;
|
||||
const isDownSwipe = distance < -50;
|
||||
|
||||
if (isUpSwipe && currentVideoIndex < videoEvents.length - 1) {
|
||||
setCurrentVideoIndex(prev => prev + 1);
|
||||
} else if (isDownSwipe && currentVideoIndex > 0) {
|
||||
setCurrentVideoIndex(prev => prev - 1);
|
||||
}
|
||||
|
||||
setTouchStart(null);
|
||||
setTouchEnd(null);
|
||||
};
|
||||
|
||||
// Play current video and pause others
|
||||
useEffect(() => {
|
||||
if (videoEvents.length === 0) return;
|
||||
|
||||
Object.entries(videoRefs.current).forEach(([id, videoElement]) => {
|
||||
if (videoElement) {
|
||||
if (id === videoEvents[currentVideoIndex]?.id) {
|
||||
videoElement.play().catch(err => console.error("Error playing video:", err));
|
||||
} else {
|
||||
videoElement.pause();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [currentVideoIndex, videoEvents]);
|
||||
|
||||
// Toggle like and send a Nostr reaction event
|
||||
const toggleLike = async (id: string) => {
|
||||
// Check if user is logged in
|
||||
const loginType = typeof window !== 'undefined' ? localStorage.getItem('loginType') : null;
|
||||
|
||||
if (!loginType) {
|
||||
toast({
|
||||
title: "Login required",
|
||||
description: "Please login to like videos",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a reaction event
|
||||
const eventToSend: Partial<NostrEvent> = {
|
||||
kind: 7,
|
||||
content: isLiked[id] ? '' : '+', // Empty content to unlike, + to like
|
||||
tags: [
|
||||
['e', id], // Reference to the video event
|
||||
['k', '22'] // Specify that we're reacting to a kind 22 event
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Sign and publish the event
|
||||
const signedEvent = await signEvent(loginType, eventToSend as NostrEvent);
|
||||
|
||||
if (signedEvent) {
|
||||
publish(signedEvent);
|
||||
|
||||
// Update UI immediately
|
||||
setIsLiked(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: isLiked[id] ? "Unliked" : "Liked",
|
||||
description: `Successfully ${isLiked[id] ? 'removed like from' : 'liked'} the video`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to sign reaction event",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending reaction:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to send reaction",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (videoEvents.length === 0) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black flex items-center justify-center text-white">
|
||||
<p>Loading videos...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Reel Feed</h2>
|
||||
{filteredEvents.map((event) => (
|
||||
// <p key={event.id}>{event.pubkey} posted: {event.content}</p>
|
||||
<div key={event.id} className="py-6">
|
||||
<NoteCard key={event.id} pubkey={event.pubkey} text={event.content} eventId={event.id} tags={event.tags} event={event} showViewNoteCardButton={true} />
|
||||
</div>
|
||||
<div
|
||||
className="fixed inset-0 bg-black overflow-hidden"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Navigation indicators */}
|
||||
<div className="absolute top-1/2 left-6 z-30 transform -translate-y-1/2">
|
||||
{currentVideoIndex > 0 && (
|
||||
<button
|
||||
className="p-2 rounded-full bg-black/20 text-white hover:bg-black/40 transition-colors"
|
||||
onClick={() => setCurrentVideoIndex(prev => Math.max(0, prev - 1))}
|
||||
>
|
||||
<ChevronUp className="h-8 w-8" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-1/2 left-6 z-30 transform translate-y-1/2">
|
||||
{currentVideoIndex < videoEvents.length - 1 && (
|
||||
<button
|
||||
className="p-2 rounded-full bg-black/20 text-white hover:bg-black/40 transition-colors"
|
||||
onClick={() => setCurrentVideoIndex(prev => Math.min(videoEvents.length - 1, prev + 1))}
|
||||
>
|
||||
<ChevronDown className="h-8 w-8" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Videos */}
|
||||
{videoEvents.map((video, index) => (
|
||||
<VideoEventDisplay
|
||||
key={video.id}
|
||||
video={video}
|
||||
index={index}
|
||||
currentIndex={currentVideoIndex}
|
||||
videoRef={el => videoRefs.current[video.id] = el}
|
||||
isLiked={!!isLiked[video.id]}
|
||||
toggleLike={() => toggleLike(video.id)}
|
||||
reactionCount={countReactionsForEvent(reactions, video.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
{/* Progress indicators */}
|
||||
<div className="absolute top-4 left-0 right-0 flex justify-center gap-1 px-4 z-30">
|
||||
{videoEvents.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"h-1 rounded-full transition-all",
|
||||
index === currentVideoIndex
|
||||
? "bg-white w-6"
|
||||
: "bg-white/40 w-4"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to count reactions for a specific event
|
||||
function countReactionsForEvent(reactions: NostrEvent[], eventId: string): number {
|
||||
if (!reactions) return 0;
|
||||
|
||||
return reactions.filter(reaction => {
|
||||
const eventTag = reaction.tags.find(tag => tag[0] === 'e');
|
||||
return eventTag && eventTag[1] === eventId && reaction.content !== '';
|
||||
}).length;
|
||||
}
|
||||
|
||||
interface VideoEventDisplayProps {
|
||||
video: VideoEvent;
|
||||
index: number;
|
||||
currentIndex: number;
|
||||
videoRef: (el: HTMLVideoElement | null) => void;
|
||||
isLiked: boolean;
|
||||
toggleLike: () => void;
|
||||
reactionCount: number;
|
||||
}
|
||||
|
||||
const VideoEventDisplay: React.FC<VideoEventDisplayProps> = ({
|
||||
video,
|
||||
index,
|
||||
currentIndex,
|
||||
videoRef,
|
||||
isLiked,
|
||||
toggleLike,
|
||||
reactionCount
|
||||
}) => {
|
||||
const { data: userData } = useProfile({
|
||||
pubkey: video.pubkey,
|
||||
});
|
||||
|
||||
const username = userData?.name || userData?.display_name ||
|
||||
`${nip19.npubEncode(video.pubkey).slice(0, 8)}...`;
|
||||
|
||||
const profileImageSrc = userData?.picture || `https://robohash.org/${video.pubkey}`;
|
||||
const npub = nip19.npubEncode(video.pubkey);
|
||||
const profileUrl = `/profile/${npub}`;
|
||||
|
||||
// Use real reaction counts
|
||||
const likesCount = reactionCount;
|
||||
const commentsCount = 0; // Could be implemented by fetching kind 1 events that reference this video
|
||||
const sharesCount = 0; // Could be implemented by tracking reposts
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 transition-transform duration-300",
|
||||
index === currentIndex ? "translate-y-0" :
|
||||
index < currentIndex ? "-translate-y-full" : "translate-y-full"
|
||||
)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={video.videoUrl}
|
||||
poster={video.imageUrl}
|
||||
className="w-full h-full object-cover"
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={index === currentIndex}
|
||||
/>
|
||||
|
||||
{/* Video info overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-white max-w-[80%]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link href={profileUrl}>
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{profileImageSrc ? (
|
||||
<img src={profileImageSrc} alt={username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="h-6 w-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div>
|
||||
<Link href={profileUrl}>
|
||||
<p className="font-bold hover:underline">{username}</p>
|
||||
</Link>
|
||||
{video.title && <p className="text-sm font-semibold">{video.title}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">{video.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Interaction buttons */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<button
|
||||
className="flex flex-col items-center hover:scale-110 transition-transform"
|
||||
onClick={toggleLike}
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
"h-8 w-8",
|
||||
isLiked ? "fill-red-500 text-red-500" : "text-white"
|
||||
)}
|
||||
/>
|
||||
<span className="text-white text-xs mt-1">{likesCount}</span>
|
||||
</button>
|
||||
<button className="flex flex-col items-center hover:scale-110 transition-transform">
|
||||
<MessageCircle className="h-8 w-8 text-white" />
|
||||
<span className="text-white text-xs mt-1">{commentsCount}</span>
|
||||
</button>
|
||||
<button className="flex flex-col items-center hover:scale-110 transition-transform">
|
||||
<Share2 className="h-8 w-8 text-white" />
|
||||
<span className="text-white text-xs mt-1">{sharesCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReelFeed;
|
||||
@@ -111,6 +111,8 @@ const UploadComponent: React.FC = () => {
|
||||
const [shouldFetch, setShouldFetch] = useState(false)
|
||||
const [serverChoice, setServerChoice] = useState("blossom.band")
|
||||
const [enableNip89, setEnableNip89] = useState(false)
|
||||
const [uploadMethod, setUploadMethod] = useState<"file" | "url">("file")
|
||||
const [imageUrl, setImageUrl] = useState("")
|
||||
|
||||
const { events, isLoading: isNoteLoading } = useNostrEvents({
|
||||
filter: shouldFetch
|
||||
@@ -166,6 +168,44 @@ const UploadComponent: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const url = event.target.value
|
||||
setImageUrl(url)
|
||||
|
||||
// Set preview if URL looks like an image
|
||||
if (url && (url.match(/\.(jpeg|jpg|gif|png|webp)$/i) || url.includes("imgur.com") || url.includes("image"))) {
|
||||
setPreviewUrl(url)
|
||||
} else {
|
||||
setPreviewUrl("")
|
||||
}
|
||||
}
|
||||
|
||||
const fetchFileFromUrl = async (url: string): Promise<File | null> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
|
||||
// Check if it's an image
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
throw new Error('URL does not point to a valid image')
|
||||
}
|
||||
|
||||
// Create a file from the blob
|
||||
const filename = url.split('/').pop() || 'image'
|
||||
const file = new File([blob], filename, { type: blob.type })
|
||||
|
||||
return file
|
||||
} catch (error) {
|
||||
console.error('Error fetching file from URL:', error)
|
||||
alert(`Error fetching image from URL: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const { value } = event.target
|
||||
|
||||
@@ -194,14 +234,32 @@ const UploadComponent: React.FC = () => {
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const desc = formData.get("description") as string
|
||||
let file = formData.get("file") as File
|
||||
let file: File | null = null
|
||||
|
||||
// Handle file upload vs URL upload
|
||||
if (uploadMethod === "file") {
|
||||
file = formData.get("file") as File
|
||||
if (file && !file.size) {
|
||||
file = null
|
||||
}
|
||||
} else if (uploadMethod === "url") {
|
||||
const url = imageUrl.trim()
|
||||
if (url) {
|
||||
file = await fetchFileFromUrl(url)
|
||||
if (!file) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sha256 = ""
|
||||
let finalNoteContent = desc
|
||||
let finalFileUrl = ""
|
||||
console.log("File:", file)
|
||||
|
||||
if (!desc && !file.size) {
|
||||
alert("Please enter a description and/or upload a file")
|
||||
if (!desc && !file) {
|
||||
alert("Please enter a description and/or upload a file or provide an image URL")
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -288,7 +346,7 @@ const UploadComponent: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (finalFileUrl) {
|
||||
if (finalFileUrl && file) {
|
||||
const image = new Image()
|
||||
image.src = URL.createObjectURL(file)
|
||||
await new Promise((resolve) => {
|
||||
@@ -384,48 +442,107 @@ const UploadComponent: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file">Image</Label>
|
||||
<div className="border-2 border-dashed rounded-lg p-6 transition-colors hover:border-primary/50 hover:bg-muted/50">
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
{previewUrl ? (
|
||||
<div className="w-full rounded-md">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ImageIcon className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{previewUrl ? "Replace image" : "Add image"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Supported formats: JPEG, PNG, WebP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="file"
|
||||
className={`relative cursor-pointer rounded-md px-4 py-2 text-sm font-medium ring-offset-background transition-colors
|
||||
${previewUrl ? 'bg-muted hover:bg-muted/80' : 'bg-primary text-primary-foreground hover:bg-primary/90'}`}
|
||||
>
|
||||
{previewUrl ? "Change file" : "Select file"}
|
||||
<Input
|
||||
id="file"
|
||||
name="file"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Label>Image Upload Method</Label>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMethod("file")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
uploadMethod === "file"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadMethod("url")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
uploadMethod === "url"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
From URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadMethod === "file" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file">Image</Label>
|
||||
<div className="border-2 border-dashed rounded-lg p-6 transition-colors hover:border-primary/50 hover:bg-muted/50">
|
||||
<div className="flex flex-col items-center space-y-4 text-center">
|
||||
{previewUrl ? (
|
||||
<div className="w-full rounded-md">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ImageIcon className="h-10 w-10 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{previewUrl ? "Replace image" : "Add image"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Supported formats: JPEG, PNG, WebP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="file"
|
||||
className={`relative cursor-pointer rounded-md px-4 py-2 text-sm font-medium ring-offset-background transition-colors
|
||||
${previewUrl ? 'bg-muted hover:bg-muted/80' : 'bg-primary text-primary-foreground hover:bg-primary/90'}`}
|
||||
>
|
||||
{previewUrl ? "Change file" : "Select file"}
|
||||
<Input
|
||||
id="file"
|
||||
name="file"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="imageUrl">Image URL</Label>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="imageUrl"
|
||||
name="imageUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value={imageUrl}
|
||||
onChange={handleUrlChange}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Enter a direct link to an image (JPEG, PNG, WebP, GIF)
|
||||
</div>
|
||||
{previewUrl && (
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="text-sm font-medium mb-2">Preview:</div>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="URL Preview"
|
||||
className="max-w-full h-auto rounded-md"
|
||||
onError={() => setPreviewUrl("")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Event as NostrEvent, finalizeEvent} from "nostr-tools";
|
||||
import { hexToBytes } from "@noble/hashes/utils"
|
||||
import { signEventWithBunker } from "./bunkerUtils";
|
||||
|
||||
// Simple blacklist for pubkeys (can be expanded later)
|
||||
export const blacklistPubkeys = new Set<string>([
|
||||
// Add any blacklisted pubkeys here if needed
|
||||
]);
|
||||
|
||||
// Check if the event has nsfw or sexy tags
|
||||
export function hasNsfwContent(tags: string[][]): boolean {
|
||||
return tags.some(tag =>
|
||||
|
||||
Reference in New Issue
Block a user