(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 198 lines 7.3 kB view raw
1import React, { useState, useRef, useEffect, useCallback } from "react"; 2import { MoreHorizontal, X } from "lucide-react"; 3import { clsx } from "clsx"; 4 5export interface MoreMenuItem { 6 label: string; 7 icon?: React.ReactNode; 8 onClick: () => void; 9 variant?: "default" | "danger"; 10 disabled?: boolean; 11} 12 13interface MoreMenuProps { 14 items: MoreMenuItem[]; 15 className?: string; 16} 17 18export default function MoreMenu({ items, className }: MoreMenuProps) { 19 const [isOpen, setIsOpen] = useState(false); 20 const [isMobile, setIsMobile] = useState(false); 21 const buttonRef = useRef<HTMLButtonElement>(null); 22 const menuRef = useRef<HTMLDivElement>(null); 23 const sheetRef = useRef<HTMLDivElement>(null); 24 const dragStartY = useRef(0); 25 const dragCurrentY = useRef(0); 26 27 useEffect(() => { 28 const check = () => setIsMobile(window.innerWidth < 640); 29 check(); 30 window.addEventListener("resize", check); 31 return () => window.removeEventListener("resize", check); 32 }, []); 33 34 useEffect(() => { 35 if (!isOpen || isMobile) return; 36 37 const handleClickOutside = (e: MouseEvent) => { 38 if ( 39 menuRef.current && 40 !menuRef.current.contains(e.target as Node) && 41 buttonRef.current && 42 !buttonRef.current.contains(e.target as Node) 43 ) { 44 setIsOpen(false); 45 } 46 }; 47 48 const handleScroll = () => setIsOpen(false); 49 const handleEscape = (e: KeyboardEvent) => { 50 if (e.key === "Escape") setIsOpen(false); 51 }; 52 53 document.addEventListener("mousedown", handleClickOutside); 54 document.addEventListener("scroll", handleScroll, true); 55 document.addEventListener("keydown", handleEscape); 56 57 return () => { 58 document.removeEventListener("mousedown", handleClickOutside); 59 document.removeEventListener("scroll", handleScroll, true); 60 document.removeEventListener("keydown", handleEscape); 61 }; 62 }, [isOpen, isMobile]); 63 64 const handleTouchStart = useCallback((e: React.TouchEvent) => { 65 dragStartY.current = e.touches[0].clientY; 66 if (sheetRef.current) sheetRef.current.style.transition = "none"; 67 }, []); 68 69 const handleTouchMove = useCallback((e: React.TouchEvent) => { 70 const delta = e.touches[0].clientY - dragStartY.current; 71 dragCurrentY.current = delta; 72 if (delta > 0 && sheetRef.current) { 73 sheetRef.current.style.transform = `translateY(${delta}px)`; 74 } 75 }, []); 76 77 const handleTouchEnd = useCallback(() => { 78 if (sheetRef.current) { 79 sheetRef.current.style.transition = "transform 0.3s ease"; 80 if (dragCurrentY.current > 100) { 81 sheetRef.current.style.transform = "translateY(100%)"; 82 setTimeout(() => setIsOpen(false), 300); 83 } else { 84 sheetRef.current.style.transform = "translateY(0)"; 85 } 86 } 87 dragCurrentY.current = 0; 88 }, []); 89 90 if (items.length === 0) return null; 91 92 return ( 93 <div className={clsx("relative", className)}> 94 <button 95 ref={buttonRef} 96 onClick={() => setIsOpen(!isOpen)} 97 className="flex items-center px-2 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all" 98 title="More options" 99 > 100 <MoreHorizontal size={16} /> 101 </button> 102 103 {isOpen && isMobile && ( 104 <> 105 <div 106 className="fixed inset-0 bg-black/40 z-[999]" 107 onClick={() => setIsOpen(false)} 108 /> 109 <div className="fixed bottom-0 left-0 right-0 z-[1000] animate-slide-up"> 110 <div 111 ref={sheetRef} 112 className="mx-2 mb-2 bg-white dark:bg-surface-900 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 overflow-hidden" 113 style={{ paddingBottom: "env(safe-area-inset-bottom)" }} 114 > 115 <div 116 className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing touch-none" 117 onTouchStart={handleTouchStart} 118 onTouchMove={handleTouchMove} 119 onTouchEnd={handleTouchEnd} 120 > 121 <div className="w-8 h-1 bg-surface-200 dark:bg-surface-700 rounded-full" /> 122 </div> 123 <div className="flex items-center justify-between px-4 pt-1 pb-2"> 124 <span className="text-sm font-semibold text-surface-900 dark:text-white"> 125 Options 126 </span> 127 <button 128 onClick={() => setIsOpen(false)} 129 className="p-1 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors" 130 > 131 <X size={16} /> 132 </button> 133 </div> 134 <div className="px-2 pb-2"> 135 {items.map((item, i) => ( 136 <button 137 key={i} 138 onClick={() => { 139 item.onClick(); 140 setIsOpen(false); 141 }} 142 disabled={item.disabled} 143 className={clsx( 144 "w-full flex items-center gap-3 px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg", 145 item.variant === "danger" 146 ? "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20" 147 : "text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800", 148 item.disabled && "opacity-50 cursor-not-allowed", 149 )} 150 > 151 {item.icon && ( 152 <span className="flex items-center justify-center w-5 h-5 text-surface-400 dark:text-surface-500"> 153 {item.icon} 154 </span> 155 )} 156 {item.label} 157 </button> 158 ))} 159 </div> 160 </div> 161 </div> 162 </> 163 )} 164 165 {isOpen && !isMobile && ( 166 <div 167 ref={menuRef} 168 className="absolute right-0 top-full mt-1 z-50 min-w-[180px] bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-lg py-1 animate-fade-in" 169 > 170 {items.map((item, i) => ( 171 <button 172 key={i} 173 onClick={() => { 174 item.onClick(); 175 setIsOpen(false); 176 }} 177 disabled={item.disabled} 178 className={clsx( 179 "w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors text-left", 180 item.variant === "danger" 181 ? "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20" 182 : "text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800", 183 item.disabled && "opacity-50 cursor-not-allowed", 184 )} 185 > 186 {item.icon && ( 187 <span className="flex-shrink-0 w-4 h-4 flex items-center justify-center"> 188 {item.icon} 189 </span> 190 )} 191 {item.label} 192 </button> 193 ))} 194 </div> 195 )} 196 </div> 197 ); 198}