(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.

mobile enhancements

+719 -415
+11 -8
web/src/components/common/Card.tsx
··· 360 360 const displayImage = ogData?.image; 361 361 362 362 return ( 363 - <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative overflow-visible"> 363 + <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all relative overflow-visible min-w-0 w-full"> 364 364 {!hideCollection && 365 365 (item.collection || (item.context && item.context.length > 0)) && ( 366 366 <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2 flex-wrap"> ··· 419 419 </div> 420 420 )} 421 421 422 - <div className="flex items-start gap-3"> 422 + <div className="flex items-start gap-3 min-w-0"> 423 423 <ProfileHoverCard did={item.author?.did}> 424 424 <a href={`/profile/${item.author?.did}`} className="shrink-0"> 425 425 <div className="rounded-full overflow-hidden"> ··· 442 442 </ProfileHoverCard> 443 443 444 444 <div className="flex-1 min-w-0"> 445 - <div className="flex items-center gap-1.5 flex-wrap"> 446 - <ProfileHoverCard did={item.author?.did}> 445 + <div className="flex items-center gap-1.5 flex-wrap min-w-0"> 446 + <ProfileHoverCard 447 + did={item.author?.did} 448 + className="min-w-0 max-w-[180px] sm:max-w-[200px]" 449 + > 447 450 <a 448 451 href={`/profile/${item.author?.did}`} 449 - className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline" 452 + className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline block truncate" 450 453 > 451 454 {item.author?.displayName || item.author?.handle} 452 455 </a> 453 456 </ProfileHoverCard> 454 - <span className="text-surface-400 dark:text-surface-500 text-sm"> 457 + <span className="text-surface-400 dark:text-surface-500 text-sm truncate max-w-[120px]"> 455 458 @{item.author?.handle} 456 459 </span> 457 460 <span className="text-surface-300 dark:text-surface-600">·</span> ··· 611 614 "shrink-0 bg-surface-200 dark:bg-surface-700 relative", 612 615 layout === "mosaic" 613 616 ? "w-full aspect-video border-b border-surface-200 dark:border-surface-700" 614 - : "w-[140px] sm:w-[180px] border-r border-surface-200 dark:border-surface-700", 617 + : "w-[90px] sm:w-[140px] border-r border-surface-200 dark:border-surface-700", 615 618 )} 616 619 > 617 620 <div className="absolute inset-0 flex items-center justify-center overflow-hidden"> ··· 653 656 <Globe size={9} /> 654 657 )} 655 658 </div> 656 - <span className="truncate max-w-[200px]"> 659 + <span className="truncate min-w-0 flex-1"> 657 660 {displayUrl || pageUrl} 658 661 </span> 659 662 </div>
+104 -5
web/src/components/common/MoreMenu.tsx
··· 1 - import React, { useState, useRef, useEffect } from "react"; 2 - import { MoreHorizontal } from "lucide-react"; 1 + import React, { useState, useRef, useEffect, useCallback } from "react"; 2 + import { MoreHorizontal, X } from "lucide-react"; 3 3 import { clsx } from "clsx"; 4 4 5 5 export interface MoreMenuItem { ··· 17 17 18 18 export default function MoreMenu({ items, className }: MoreMenuProps) { 19 19 const [isOpen, setIsOpen] = useState(false); 20 + const [isMobile, setIsMobile] = useState(false); 20 21 const buttonRef = useRef<HTMLButtonElement>(null); 21 22 const menuRef = useRef<HTMLDivElement>(null); 23 + const sheetRef = useRef<HTMLDivElement>(null); 24 + const dragStartY = useRef(0); 25 + const dragCurrentY = useRef(0); 22 26 23 27 useEffect(() => { 24 - if (!isOpen) return; 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; 25 36 26 37 const handleClickOutside = (e: MouseEvent) => { 27 38 if ( ··· 48 59 document.removeEventListener("scroll", handleScroll, true); 49 60 document.removeEventListener("keydown", handleEscape); 50 61 }; 51 - }, [isOpen]); 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 + }, []); 52 89 53 90 if (items.length === 0) return null; 54 91 ··· 63 100 <MoreHorizontal size={16} /> 64 101 </button> 65 102 66 - {isOpen && ( 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 && ( 67 166 <div 68 167 ref={menuRef} 69 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"
+12 -6
web/src/components/feed/Composer.tsx
··· 175 175 176 176 return ( 177 177 <form onSubmit={handleSubmit} className="flex flex-col gap-4"> 178 - <div className="flex items-center justify-between"> 179 - <h3 className="flex items-center gap-2 text-lg font-bold text-surface-900 dark:text-white"> 178 + <div className="flex items-start justify-between gap-2"> 179 + <h3 className="flex items-center gap-2 text-lg font-bold text-surface-900 dark:text-white shrink-0"> 180 180 <ModeIcon 181 181 size={18} 182 182 className={ ··· 188 188 {modeCopy.title} 189 189 </h3> 190 190 {url && ( 191 - <div className="text-xs text-surface-400 dark:text-surface-500 max-w-[200px] truncate"> 192 - {url} 191 + <div className="text-xs text-surface-400 dark:text-surface-500 truncate min-w-0 pt-1 text-right"> 192 + {(() => { 193 + try { 194 + return new URL(url).hostname.replace(/^www\./, ""); 195 + } catch { 196 + return url; 197 + } 198 + })()} 193 199 </div> 194 200 )} 195 201 </div> ··· 309 315 )} 310 316 </div> 311 317 312 - <div className="flex items-center justify-between pt-2"> 318 + <div className="flex items-center justify-between pt-2 gap-2"> 313 319 <span 314 320 className={ 315 321 text.length > 2900 ··· 319 325 > 320 326 {text.length}/3000 321 327 </span> 322 - <div className="flex items-center gap-2"> 328 + <div className="flex items-center gap-2 shrink-0"> 323 329 {onCancel && ( 324 330 <button 325 331 type="button"
+86 -56
web/src/components/modals/AddToCollectionModal.tsx
··· 1 - import React, { useState, useEffect, useCallback } from "react"; 1 + import React, { useState, useEffect, useCallback, useRef } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 3 import { 4 4 X, ··· 43 43 const [addingTo, setAddingTo] = useState<string | null>(null); 44 44 const [addedTo, setAddedTo] = useState<Set<string>>(new Set()); 45 45 const [error, setError] = useState<string | null>(null); 46 + 47 + const sheetRef = useRef<HTMLDivElement>(null); 48 + const dragStartY = useRef(0); 49 + const dragCurrentY = useRef(0); 50 + 51 + const handleTouchStart = (e: React.TouchEvent) => { 52 + dragStartY.current = e.touches[0].clientY; 53 + if (sheetRef.current) sheetRef.current.style.transition = "none"; 54 + }; 55 + 56 + const handleTouchMove = (e: React.TouchEvent) => { 57 + const delta = e.touches[0].clientY - dragStartY.current; 58 + dragCurrentY.current = delta; 59 + if (delta > 0 && sheetRef.current) { 60 + sheetRef.current.style.transform = `translateY(${delta}px)`; 61 + } 62 + }; 63 + 64 + const handleTouchEnd = () => { 65 + if (sheetRef.current) { 66 + sheetRef.current.style.transition = "transform 0.3s ease"; 67 + if (dragCurrentY.current > 100) { 68 + sheetRef.current.style.transform = "translateY(100%)"; 69 + setTimeout(onClose, 300); 70 + } else { 71 + sheetRef.current.style.transform = "translateY(0)"; 72 + } 73 + } 74 + dragCurrentY.current = 0; 75 + }; 46 76 47 77 const [showNewForm, setShowNewForm] = useState(false); 48 78 const [newName, setNewName] = useState(""); ··· 134 164 135 165 return ( 136 166 <div 137 - className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" 167 + className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center sm:p-4 bg-black/40 backdrop-blur-sm animate-fade-in" 138 168 onClick={onClose} 139 169 > 140 170 <div 141 - className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden" 171 + ref={sheetRef} 172 + className="w-full sm:max-w-md bg-white dark:bg-surface-900 rounded-t-3xl sm:rounded-3xl shadow-2xl flex flex-col animate-slide-up border border-surface-200 dark:border-surface-700 border-b-0 sm:border-b" 173 + style={{ 174 + paddingBottom: "env(safe-area-inset-bottom)", 175 + maxHeight: "90dvh", 176 + }} 142 177 onClick={(e) => e.stopPropagation()} 143 178 > 144 - <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 145 - <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 179 + <div 180 + className="flex justify-center pt-3 pb-1 sm:hidden shrink-0 cursor-grab active:cursor-grabbing touch-none" 181 + onTouchStart={handleTouchStart} 182 + onTouchMove={handleTouchMove} 183 + onTouchEnd={handleTouchEnd} 184 + > 185 + <div className="w-8 h-1 bg-surface-200 dark:bg-surface-700 rounded-full" /> 186 + </div> 187 + 188 + <div className="px-4 sm:px-4 py-3 flex justify-between items-center border-b border-surface-100 dark:border-surface-800 shrink-0"> 189 + <h2 className="text-lg font-display font-bold text-surface-900 dark:text-white"> 146 190 {t("addToCollection.title")} 147 191 </h2> 148 192 <button 149 193 onClick={onClose} 150 194 className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" 151 195 > 152 - <X size={20} /> 196 + <X size={18} /> 153 197 </button> 154 198 </div> 155 199 156 - <div className="px-6 pb-6 pt-4"> 200 + <div className="px-4 sm:px-6 pb-4 pt-4 overflow-y-auto flex-1"> 157 201 {loading ? ( 158 202 <div className="text-center py-10"> 159 203 <Loader2 ··· 299 343 </div> 300 344 )} 301 345 302 - <div className="flex gap-3 pt-2"> 346 + <div className="flex gap-2 pt-2"> 303 347 <button 304 348 type="button" 305 - className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 349 + className="flex-1 py-2.5 text-sm font-medium text-surface-600 dark:text-surface-300 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-xl transition-colors" 306 350 onClick={() => { 307 351 setShowNewForm(false); 308 352 setNewDescription(""); ··· 314 358 </button> 315 359 <button 316 360 type="submit" 317 - className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" 361 + className="flex-1 py-2.5 text-sm bg-primary-600 text-white font-medium rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" 318 362 disabled={!newName.trim() || creating} 319 363 > 320 - {creating && <Loader2 size={16} className="animate-spin" />} 364 + {creating && <Loader2 size={14} className="animate-spin" />} 321 365 {creating 322 366 ? t("addToCollection.creating") 323 367 : t("addToCollection.create")} ··· 325 369 </div> 326 370 </form> 327 371 ) : ( 328 - <div> 372 + <div className="-mx-4 sm:mx-0"> 329 373 {error && ( 330 - <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 374 + <div className="mx-4 sm:mx-0 mb-2 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 331 375 {error} 332 376 </div> 333 377 )} 334 378 335 379 <button 336 - className="w-full flex items-center gap-4 p-4 bg-white dark:bg-surface-800 border-2 border-primary-100 dark:border-primary-900/50 hover:border-primary-300 dark:hover:border-primary-700 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left mb-4" 380 + className="w-full flex items-center gap-3 px-4 sm:px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg text-primary-600 dark:text-primary-400 hover:bg-surface-50 dark:hover:bg-surface-800" 337 381 onClick={() => setShowNewForm(true)} 338 382 > 339 - <div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-full flex items-center justify-center text-primary-600 dark:text-primary-400 flex-shrink-0"> 340 - <FolderPlus size={20} /> 341 - </div> 342 - <div className="flex-1 min-w-0"> 343 - <h3 className="font-bold text-surface-900 dark:text-white group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors"> 344 - {t("addToCollection.newCollectionButton")} 345 - </h3> 346 - <span className="text-sm text-surface-500 dark:text-surface-400"> 347 - {t("addToCollection.createNewDescription")} 348 - </span> 349 - </div> 383 + <span className="flex items-center justify-center w-5 h-5 text-primary-500 dark:text-primary-400"> 384 + <FolderPlus size={16} /> 385 + </span> 386 + <span className="flex-1 text-left"> 387 + {t("addToCollection.newCollectionButton")} 388 + </span> 350 389 <ChevronRight 351 - size={20} 352 - className="text-surface-300 dark:text-surface-600 group-hover:text-primary-500 dark:group-hover:text-primary-400" 390 + size={14} 391 + className="text-surface-300 dark:text-surface-600" 353 392 /> 354 393 </button> 355 394 395 + <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-4 sm:mx-2" /> 396 + 356 397 {collections.length === 0 ? ( 357 - <div className="text-center py-6"> 358 - <p className="text-surface-500 dark:text-surface-400"> 398 + <div className="text-center py-6 px-4"> 399 + <p className="text-sm text-surface-400 dark:text-surface-500"> 359 400 {t("addToCollection.none")} 360 401 </p> 361 402 </div> 362 403 ) : ( 363 - <div className="space-y-2 max-h-[300px] overflow-y-auto"> 404 + <div className="overflow-y-auto max-h-[50vh] sm:max-h-[300px]"> 364 405 {collections.map((col) => { 365 406 const isAdded = addedTo.has(col.uri); 366 407 const isAdding = addingTo === col.uri; ··· 370 411 key={col.uri} 371 412 onClick={() => handleAdd(col.uri)} 372 413 disabled={isAdding || isAdded} 373 - className="w-full flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800/50 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors text-left group disabled:opacity-70" 414 + className="w-full flex items-center gap-3 px-4 sm:px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800 disabled:opacity-60" 374 415 > 375 - <div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-surface-700 rounded-full shadow-sm text-surface-600 dark:text-surface-300"> 376 - <CollectionIcon icon={col.icon} size={18} /> 377 - </div> 378 - <div className="flex-1 min-w-0"> 379 - <h3 className="text-sm font-bold text-surface-900 dark:text-white"> 380 - {col.name} 381 - </h3> 382 - {col.description && ( 383 - <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1"> 384 - {col.description} 385 - </p> 386 - )} 387 - </div> 416 + <span className="flex items-center justify-center w-5 h-5 text-surface-400 dark:text-surface-500"> 417 + <CollectionIcon icon={col.icon} size={16} /> 418 + </span> 419 + <span className="flex-1 text-left truncate"> 420 + {col.name} 421 + </span> 388 422 {isAdding ? ( 389 423 <Loader2 390 - size={16} 391 - className="animate-spin text-surface-400" 424 + size={15} 425 + className="animate-spin text-surface-400 shrink-0" 392 426 /> 393 427 ) : isAdded ? ( 394 - <Check size={16} className="text-green-500" /> 428 + <Check 429 + size={15} 430 + className="text-green-500 shrink-0" 431 + /> 395 432 ) : ( 396 433 <Plus 397 - size={16} 398 - className="text-surface-300 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300" 434 + size={15} 435 + className="text-surface-300 dark:text-surface-600 shrink-0" 399 436 /> 400 437 )} 401 438 </button> ··· 403 440 })} 404 441 </div> 405 442 )} 406 - 407 - <button 408 - onClick={onClose} 409 - className="w-full mt-4 py-3 bg-surface-900 dark:bg-white text-white dark:text-surface-900 font-semibold rounded-xl hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors" 410 - > 411 - {t("addToCollection.done")} 412 - </button> 413 443 </div> 414 444 )} 415 445 </div>
+162 -79
web/src/components/modals/ShareMenu.tsx
··· 1 - import React, { useState, useRef, useEffect } from "react"; 1 + import React, { useState, useRef, useEffect, useCallback } from "react"; 2 2 import { 3 3 Copy, 4 4 ExternalLink, 5 5 Check, 6 6 Share2, 7 7 MoreHorizontal, 8 + X, 8 9 } from "lucide-react"; 9 10 import { 10 11 AturiIcon, ··· 43 44 const { t } = useTranslation(); 44 45 const [isOpen, setIsOpen] = useState(false); 45 46 const [copied, setCopied] = useState<string | null>(null); 47 + const [isMobile, setIsMobile] = useState(false); 46 48 const menuRef = useRef<HTMLDivElement>(null); 47 49 const buttonRef = useRef<HTMLButtonElement>(null); 50 + const sheetRef = useRef<HTMLDivElement>(null); 51 + const dragStartY = useRef(0); 52 + const dragCurrentY = useRef(0); 53 + 54 + const handleTouchStart = useCallback((e: React.TouchEvent) => { 55 + dragStartY.current = e.touches[0].clientY; 56 + if (sheetRef.current) sheetRef.current.style.transition = "none"; 57 + }, []); 58 + 59 + const handleTouchMove = useCallback((e: React.TouchEvent) => { 60 + const delta = e.touches[0].clientY - dragStartY.current; 61 + dragCurrentY.current = delta; 62 + if (delta > 0 && sheetRef.current) { 63 + sheetRef.current.style.transform = `translateY(${delta}px)`; 64 + } 65 + }, []); 66 + 67 + const handleTouchEnd = useCallback(() => { 68 + if (sheetRef.current) { 69 + sheetRef.current.style.transition = "transform 0.3s ease"; 70 + if (dragCurrentY.current > 100) { 71 + sheetRef.current.style.transform = "translateY(100%)"; 72 + setTimeout(() => setIsOpen(false), 300); 73 + } else { 74 + sheetRef.current.style.transform = "translateY(0)"; 75 + } 76 + } 77 + dragCurrentY.current = 0; 78 + }, []); 48 79 const [menuPosition, setMenuPosition] = useState({ 49 80 top: 0, 50 81 left: 0, 51 82 alignRight: false, 52 83 }); 84 + 85 + useEffect(() => { 86 + const check = () => setIsMobile(window.innerWidth < 640); 87 + check(); 88 + window.addEventListener("resize", check); 89 + return () => window.removeEventListener("resize", check); 90 + }, []); 53 91 54 92 const getShareUrl = () => { 55 93 if (customUrl) return customUrl; ··· 138 176 setIsOpen(false); 139 177 } 140 178 }; 141 - if (isOpen) { 179 + if (isOpen && !isMobile) { 142 180 document.addEventListener("mousedown", handleClickOutside); 143 181 window.addEventListener("scroll", () => setIsOpen(false), true); 144 182 window.addEventListener("resize", () => setIsOpen(false)); ··· 148 186 window.removeEventListener("scroll", () => setIsOpen(false), true); 149 187 window.removeEventListener("resize", () => setIsOpen(false)); 150 188 }; 151 - }, [isOpen]); 189 + }, [isOpen, isMobile]); 152 190 153 191 const calculatePosition = () => { 154 192 if (!buttonRef.current) return; 155 193 const rect = buttonRef.current.getBoundingClientRect(); 156 - const menuWidth = 240; 194 + const menuWidth = 260; 195 + const padding = 8; 157 196 158 197 let top = rect.bottom + 8; 159 198 let left = rect.left; 160 199 let alignRight = false; 161 200 162 - if (left + menuWidth > window.innerWidth - 16) { 201 + if (left + menuWidth > window.innerWidth - padding) { 163 202 left = rect.right - menuWidth; 164 203 alignRight = true; 165 204 } 166 205 206 + left = Math.max( 207 + padding, 208 + Math.min(left, window.innerWidth - menuWidth - padding), 209 + ); 210 + 167 211 if (top + 300 > window.innerHeight) { 168 212 top = rect.top - 8; 169 213 } ··· 172 216 }; 173 217 174 218 const toggleMenu = () => { 175 - if (!isOpen) calculatePosition(); 219 + if (!isOpen && !isMobile) calculatePosition(); 176 220 setIsOpen(!isOpen); 177 221 }; 178 222 ··· 227 271 { name: "Deer", domain: "deer.social", icon: <DeerIcon size={18} /> }, 228 272 ]; 229 273 274 + const menuContent = ( 275 + <div className="flex flex-col gap-0.5"> 276 + {isSemble ? ( 277 + <> 278 + <div className="px-3 py-2 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider flex items-center gap-1.5 select-none"> 279 + <SembleLogo /> 280 + {t("shareMenu.sembleIntegration")} 281 + </div> 282 + {renderMenuItem( 283 + t("shareMenu.openOnSemble"), 284 + <ExternalLink size={16} />, 285 + () => window.open(sembleUrl, "_blank"), 286 + false, 287 + true, 288 + )} 289 + {renderMenuItem( 290 + t("shareMenu.copySembleLink"), 291 + <Copy size={16} />, 292 + () => handleCopy(sembleUrl, "semble"), 293 + copied === "semble", 294 + )} 295 + <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 296 + </> 297 + ) : null} 298 + 299 + {renderMenuItem( 300 + t("shareMenu.copyLink"), 301 + <Copy size={16} />, 302 + () => handleCopy(shareUrl, "link"), 303 + copied === "link", 304 + )} 305 + 306 + <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 307 + {t("shareMenu.shareViaApp")} 308 + </div> 309 + 310 + <div className="grid grid-cols-5 gap-1 px-1 mb-1"> 311 + {shareForks.map((fork) => ( 312 + <button 313 + key={fork.domain} 314 + onClick={() => handleShareToFork(fork.domain)} 315 + className="flex items-center justify-center p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 hover:scale-105 transition-all text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white" 316 + title={`Share to ${fork.name}`} 317 + > 318 + {fork.icon} 319 + </button> 320 + ))} 321 + </div> 322 + 323 + <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 324 + 325 + {renderMenuItem( 326 + t("shareMenu.copyUniversalLink"), 327 + <AturiIcon size={16} />, 328 + () => handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), 329 + copied === "aturi", 330 + )} 331 + 332 + {typeof navigator !== "undefined" && 333 + navigator.share && 334 + renderMenuItem( 335 + t("shareMenu.moreOptions"), 336 + <MoreHorizontal size={16} />, 337 + () => { 338 + navigator 339 + .share({ title: "Margin", text, url: shareUrl }) 340 + .catch(() => {}); 341 + setIsOpen(false); 342 + }, 343 + )} 344 + </div> 345 + ); 346 + 230 347 return ( 231 348 <div className="relative inline-block"> 232 349 <button ··· 238 355 <Share2 size={16} /> 239 356 </button> 240 357 241 - {isOpen && ( 358 + {isOpen && isMobile && ( 359 + <> 360 + <div 361 + className="fixed inset-0 bg-black/40 z-[999]" 362 + onClick={() => setIsOpen(false)} 363 + /> 364 + <div className="fixed bottom-0 left-0 right-0 z-[1000] animate-slide-up"> 365 + <div 366 + ref={sheetRef} 367 + 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" 368 + style={{ paddingBottom: "env(safe-area-inset-bottom)" }} 369 + > 370 + <div 371 + className="flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing touch-none" 372 + onTouchStart={handleTouchStart} 373 + onTouchMove={handleTouchMove} 374 + onTouchEnd={handleTouchEnd} 375 + > 376 + <div className="w-8 h-1 bg-surface-200 dark:bg-surface-700 rounded-full" /> 377 + </div> 378 + <div className="flex items-center justify-between px-4 pt-1 pb-2"> 379 + <span className="text-sm font-semibold text-surface-900 dark:text-white"> 380 + {t("shareMenu.share", { defaultValue: "Share" })} 381 + </span> 382 + <button 383 + onClick={() => setIsOpen(false)} 384 + 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" 385 + > 386 + <X size={16} /> 387 + </button> 388 + </div> 389 + <div className="px-2 pb-2">{menuContent}</div> 390 + </div> 391 + </div> 392 + </> 393 + )} 394 + {isOpen && !isMobile && ( 242 395 <div 243 396 ref={menuRef} 244 - className="fixed z-[1000] w-[260px] bg-white dark:bg-surface-900 rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/5 p-1.5 animate-in fade-in zoom-in-95 duration-150 origin-top-left" 397 + className="fixed z-[1000] w-[260px] bg-white dark:bg-surface-900 rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/5 p-1.5 animate-in fade-in zoom-in-95 duration-150" 245 398 style={{ 246 399 top: menuPosition.top, 247 400 left: menuPosition.left, 248 401 transformOrigin: menuPosition.alignRight ? "top right" : "top left", 249 402 }} 250 403 > 251 - <div className="flex flex-col gap-0.5"> 252 - {isSemble ? ( 253 - <> 254 - <div className="px-3 py-2 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider flex items-center gap-1.5 select-none"> 255 - <SembleLogo /> 256 - {t("shareMenu.sembleIntegration")} 257 - </div> 258 - {renderMenuItem( 259 - t("shareMenu.openOnSemble"), 260 - <ExternalLink size={16} />, 261 - () => window.open(sembleUrl, "_blank"), 262 - false, 263 - true, 264 - )} 265 - {renderMenuItem( 266 - t("shareMenu.copySembleLink"), 267 - <Copy size={16} />, 268 - () => handleCopy(sembleUrl, "semble"), 269 - copied === "semble", 270 - )} 271 - <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 272 - </> 273 - ) : null} 274 - 275 - {renderMenuItem( 276 - t("shareMenu.copyLink"), 277 - <Copy size={16} />, 278 - () => handleCopy(shareUrl, "link"), 279 - copied === "link", 280 - )} 281 - 282 - <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 283 - {t("shareMenu.shareViaApp")} 284 - </div> 285 - 286 - <div className="grid grid-cols-5 gap-1 px-1 mb-1"> 287 - {shareForks.map((fork) => ( 288 - <button 289 - key={fork.domain} 290 - onClick={() => handleShareToFork(fork.domain)} 291 - className="flex items-center justify-center p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 hover:scale-105 transition-all text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white" 292 - title={`Share to ${fork.name}`} 293 - > 294 - {fork.icon} 295 - </button> 296 - ))} 297 - </div> 298 - 299 - <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 300 - 301 - {renderMenuItem( 302 - t("shareMenu.copyUniversalLink"), 303 - <AturiIcon size={16} />, 304 - () => 305 - handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), 306 - copied === "aturi", 307 - )} 308 - 309 - {typeof navigator !== "undefined" && 310 - navigator.share && 311 - renderMenuItem( 312 - t("shareMenu.moreOptions"), 313 - <MoreHorizontal size={16} />, 314 - () => { 315 - navigator 316 - .share({ title: "Margin", text, url: shareUrl }) 317 - .catch(() => {}); 318 - setIsOpen(false); 319 - }, 320 - )} 321 - </div> 404 + {menuContent} 322 405 </div> 323 406 )} 324 407 </div>
+277 -206
web/src/components/navigation/MobileNav.tsx
··· 7 7 Home, 8 8 LogOut, 9 9 MessageSquareText, 10 - MoreHorizontal, 11 10 PenSquare, 12 11 Search, 13 12 Settings, 14 13 User, 15 14 X, 16 15 } from "lucide-react"; 17 - import React, { useEffect, useState } from "react"; 16 + import { useEffect, useState } from "react"; 18 17 import { getUnreadNotificationCount } from "../../api/client"; 19 18 import { $user, logout } from "../../store/auth"; 20 19 import { AppleIcon } from "../common/Icons"; ··· 56 55 <> 57 56 {isMenuOpen && ( 58 57 <div 59 - className="fixed inset-0 bg-black/50 z-40 md:hidden" 58 + className="fixed inset-0 bg-black/40 z-40 md:hidden" 60 59 onClick={closeMenu} 61 60 /> 62 61 )} 63 62 64 63 {isMenuOpen && ( 65 - <div className="fixed bottom-16 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-2xl shadow-2xl z-50 md:hidden animate-slide-up"> 66 - <div className="p-4 space-y-1"> 67 - {isAuthenticated && user ? ( 68 - <> 69 - <a 70 - href={`/profile/${user.did}`} 71 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 72 - onClick={(e) => { 73 - if (onNavigate) { 74 - e.preventDefault(); 75 - onNavigate(`/profile/${user.did}`); 76 - } 77 - closeMenu(); 78 - }} 79 - > 80 - {user.avatar ? ( 81 - <img 82 - src={user.avatar} 83 - alt="" 84 - className="w-10 h-10 rounded-full object-cover" 85 - /> 86 - ) : ( 87 - <div className="w-10 h-10 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center"> 88 - <User size={18} className="text-surface-500" /> 89 - </div> 90 - )} 91 - <div className="flex flex-col"> 92 - <span className="font-semibold text-surface-900 dark:text-white"> 93 - {user.displayName || user.handle} 94 - </span> 95 - <span className="text-sm text-surface-500"> 96 - @{user.handle} 97 - </span> 98 - </div> 99 - </a> 100 - 101 - <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 64 + <div 65 + className="fixed left-0 right-0 z-50 md:hidden animate-slide-up" 66 + style={{ bottom: "calc(3.5rem + env(safe-area-inset-bottom))" }} 67 + > 68 + <div 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"> 69 + <div className="flex justify-center pt-3 pb-1"> 70 + <div className="w-8 h-1 bg-surface-200 dark:bg-surface-600 rounded-full" /> 71 + </div> 102 72 103 - {[ 104 - { 105 - href: "/annotations", 106 - icon: MessageSquareText, 107 - label: t("nav.annotations"), 108 - }, 109 - { 110 - href: "/highlights", 111 - icon: Highlighter, 112 - label: t("nav.highlights"), 113 - }, 114 - { 115 - href: "/bookmarks", 116 - icon: Bookmark, 117 - label: t("nav.bookmarks"), 118 - }, 119 - { 120 - href: "/collections", 121 - icon: Folder, 122 - label: t("nav.collections"), 123 - }, 124 - { 125 - href: "/settings", 126 - icon: Settings, 127 - label: t("nav.settings"), 128 - }, 129 - ].map(({ href, icon: Icon, label }) => ( 73 + <div className="p-2"> 74 + {isAuthenticated && user ? ( 75 + <> 130 76 <a 131 - key={href} 132 - href={href} 133 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 77 + href={`/profile/${user.did}`} 78 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors" 134 79 onClick={(e) => { 135 80 if (onNavigate) { 136 81 e.preventDefault(); 137 - onNavigate(href); 82 + onNavigate(`/profile/${user.did}`); 138 83 } 139 84 closeMenu(); 140 85 }} 141 86 > 142 - <Icon size={20} /> 143 - <span>{label}</span> 87 + {user.avatar ? ( 88 + <img 89 + src={user.avatar} 90 + alt="" 91 + className="w-9 h-9 rounded-full object-cover shrink-0" 92 + /> 93 + ) : ( 94 + <div className="w-9 h-9 rounded-full bg-surface-100 dark:bg-surface-700 flex items-center justify-center shrink-0"> 95 + <User size={16} className="text-surface-500" /> 96 + </div> 97 + )} 98 + <div className="flex flex-col min-w-0"> 99 + <span className="font-semibold text-surface-900 dark:text-white text-sm truncate"> 100 + {user.displayName || user.handle} 101 + </span> 102 + <span className="text-xs text-surface-400 dark:text-surface-500 truncate"> 103 + @{user.handle} 104 + </span> 105 + </div> 144 106 </a> 145 - ))} 146 107 147 - <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 108 + <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" /> 148 109 149 - <a 150 - href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3" 151 - target="_blank" 152 - rel="noopener noreferrer" 153 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 154 - onClick={closeMenu} 155 - > 156 - <AppleIcon size={20} /> 157 - <span>{t("mobileNav.iosShortcut")}</span> 158 - </a> 110 + <div className="grid grid-cols-2 gap-1"> 111 + {[ 112 + { 113 + href: "/annotations", 114 + icon: MessageSquareText, 115 + label: t("nav.annotations"), 116 + }, 117 + { 118 + href: "/highlights", 119 + icon: Highlighter, 120 + label: t("nav.highlights"), 121 + }, 122 + { 123 + href: "/bookmarks", 124 + icon: Bookmark, 125 + label: t("nav.bookmarks"), 126 + }, 127 + { 128 + href: "/collections", 129 + icon: Folder, 130 + label: t("nav.collections"), 131 + }, 132 + ].map(({ href, icon: Icon, label }) => ( 133 + <a 134 + key={href} 135 + href={href} 136 + className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200" 137 + onClick={(e) => { 138 + if (onNavigate) { 139 + e.preventDefault(); 140 + onNavigate(href); 141 + } 142 + closeMenu(); 143 + }} 144 + > 145 + <Icon size={16} className="shrink-0" /> 146 + <span className="text-sm font-medium truncate"> 147 + {label} 148 + </span> 149 + </a> 150 + ))} 151 + </div> 159 152 160 - <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 153 + <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" /> 154 + 155 + <div className="flex gap-1"> 156 + <a 157 + href="/settings" 158 + className="flex-1 flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200" 159 + onClick={(e) => { 160 + if (onNavigate) { 161 + e.preventDefault(); 162 + onNavigate("/settings"); 163 + } 164 + closeMenu(); 165 + }} 166 + > 167 + <Settings size={16} className="shrink-0" /> 168 + <span className="text-sm font-medium"> 169 + {t("nav.settings")} 170 + </span> 171 + </a> 161 172 162 - <button 163 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 w-full" 164 - onClick={() => { 165 - logout(); 166 - closeMenu(); 167 - }} 168 - > 169 - <LogOut size={20} /> 170 - <span>{t("nav.logOut")}</span> 171 - </button> 172 - </> 173 - ) : ( 174 - <> 175 - <a 176 - href="/login" 177 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 178 - onClick={closeMenu} 179 - > 180 - <User size={20} /> 181 - <span>{t("nav.signIn")}</span> 182 - </a> 183 - {[ 184 - { 185 - href: "/collections", 186 - icon: Folder, 187 - label: t("nav.collections"), 188 - }, 189 - { 190 - href: "/settings", 191 - icon: Settings, 192 - label: t("nav.settings"), 193 - }, 194 - ].map(({ href, icon: Icon, label }) => ( 195 - <a 196 - key={href} 197 - href={href} 198 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 199 - onClick={(e) => { 200 - if (onNavigate) { 201 - e.preventDefault(); 202 - onNavigate(href); 203 - } 173 + <a 174 + href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3" 175 + target="_blank" 176 + rel="noopener noreferrer" 177 + className="flex-1 flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200" 178 + onClick={closeMenu} 179 + > 180 + <AppleIcon size={16} /> 181 + <span className="text-sm font-medium"> 182 + {t("mobileNav.iosShortcut")} 183 + </span> 184 + </a> 185 + </div> 186 + 187 + <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" /> 188 + 189 + <button 190 + className="w-full flex items-center gap-2.5 p-3 rounded-xl hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors text-red-500 dark:text-red-400" 191 + onClick={() => { 192 + logout(); 204 193 closeMenu(); 205 194 }} 206 195 > 207 - <Icon size={20} /> 208 - <span>{label}</span> 196 + <LogOut size={16} className="shrink-0" /> 197 + <span className="text-sm font-medium"> 198 + {t("nav.logOut")} 199 + </span> 200 + </button> 201 + </> 202 + ) : ( 203 + <> 204 + <a 205 + href="/login" 206 + className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200" 207 + onClick={closeMenu} 208 + > 209 + <User size={16} className="shrink-0" /> 210 + <span className="text-sm font-medium"> 211 + {t("nav.signIn")} 212 + </span> 209 213 </a> 210 - ))} 214 + {[ 215 + { 216 + href: "/collections", 217 + icon: Folder, 218 + label: t("nav.collections"), 219 + }, 220 + { 221 + href: "/settings", 222 + icon: Settings, 223 + label: t("nav.settings"), 224 + }, 225 + ].map(({ href, icon: Icon, label }) => ( 226 + <a 227 + key={href} 228 + href={href} 229 + className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200" 230 + onClick={(e) => { 231 + if (onNavigate) { 232 + e.preventDefault(); 233 + onNavigate(href); 234 + } 235 + closeMenu(); 236 + }} 237 + > 238 + <Icon size={16} className="shrink-0" /> 239 + <span className="text-sm font-medium">{label}</span> 240 + </a> 241 + ))} 211 242 212 - <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 243 + <div className="h-px bg-surface-100 dark:bg-surface-700 my-1 mx-3" /> 213 244 214 - <a 215 - href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3" 216 - target="_blank" 217 - rel="noopener noreferrer" 218 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 219 - onClick={closeMenu} 220 - > 221 - <AppleIcon size={20} /> 222 - <span>{t("mobileNav.iosShortcut")}</span> 223 - </a> 224 - </> 225 - )} 245 + <a 246 + href="https://www.icloud.com/shortcuts/1e33ebf52f55431fae1e187cfe9738c3" 247 + target="_blank" 248 + rel="noopener noreferrer" 249 + className="flex items-center gap-2.5 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors text-surface-700 dark:text-surface-200" 250 + onClick={closeMenu} 251 + > 252 + <AppleIcon size={16} /> 253 + <span className="text-sm font-medium"> 254 + {t("mobileNav.iosShortcut")} 255 + </span> 256 + </a> 257 + </> 258 + )} 259 + </div> 226 260 </div> 227 261 </div> 228 262 )} 229 263 230 - <nav className="fixed bottom-0 left-0 right-0 h-14 bg-white/90 dark:bg-surface-900/90 backdrop-blur-md border-t border-surface-200 dark:border-surface-700 flex items-center justify-around px-2 z-50 md:hidden safe-area-bottom"> 264 + <nav 265 + className="fixed bottom-0 left-0 right-0 bg-white/95 dark:bg-surface-900/95 backdrop-blur-md border-t border-surface-200 dark:border-surface-800 flex items-center justify-around z-50 md:hidden" 266 + style={{ 267 + height: "calc(3.5rem + env(safe-area-inset-bottom))", 268 + paddingBottom: "env(safe-area-inset-bottom)", 269 + }} 270 + > 231 271 <a 232 272 href="/home" 233 - className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 234 - isActive("/home") 235 - ? "text-primary-600" 236 - : "text-surface-500 hover:text-surface-700" 237 - }`} 273 + className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 transition-colors" 238 274 onClick={(e) => { 239 275 if (onNavigate) { 240 276 e.preventDefault(); ··· 244 280 closeMenu(); 245 281 }} 246 282 > 247 - <Home size={24} strokeWidth={1.5} /> 283 + <div 284 + className={`p-2 rounded-xl transition-colors ${ 285 + isActive("/home") 286 + ? "bg-primary-50 dark:bg-primary-950/50 text-primary-600 dark:text-primary-400" 287 + : "text-surface-400 dark:text-surface-500" 288 + }`} 289 + > 290 + <Home size={22} strokeWidth={isActive("/home") ? 2 : 1.5} /> 291 + </div> 248 292 </a> 249 293 250 294 <a 251 295 href="/search" 252 - className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 253 - isActive("/search") 254 - ? "text-primary-600" 255 - : "text-surface-500 hover:text-surface-700" 256 - }`} 296 + className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 transition-colors" 257 297 onClick={(e) => { 258 298 if (onNavigate) { 259 299 e.preventDefault(); ··· 263 303 closeMenu(); 264 304 }} 265 305 > 266 - <Search size={24} strokeWidth={1.5} /> 306 + <div 307 + className={`p-2 rounded-xl transition-colors ${ 308 + isActive("/search") 309 + ? "bg-primary-50 dark:bg-primary-950/50 text-primary-600 dark:text-primary-400" 310 + : "text-surface-400 dark:text-surface-500" 311 + }`} 312 + > 313 + <Search size={22} strokeWidth={isActive("/search") ? 2 : 1.5} /> 314 + </div> 267 315 </a> 268 316 269 317 {isAuthenticated ? ( 270 - <> 271 - <a 272 - href="/new" 273 - className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 274 - onClick={(e) => { 275 - if (onNavigate) { 276 - e.preventDefault(); 277 - onNavigate("/new"); 278 - } 279 - setCurrentPath("/new"); 280 - closeMenu(); 281 - }} 282 - > 283 - <PenSquare size={20} strokeWidth={2} /> 284 - </a> 318 + <a 319 + href="/new" 320 + className="flex items-center justify-center w-11 h-11 rounded-2xl bg-primary-600 dark:bg-primary-600 text-white shadow-md active:scale-95 transition-transform" 321 + onClick={(e) => { 322 + if (onNavigate) { 323 + e.preventDefault(); 324 + onNavigate("/new"); 325 + } 326 + setCurrentPath("/new"); 327 + closeMenu(); 328 + }} 329 + > 330 + <PenSquare size={18} strokeWidth={2} /> 331 + </a> 332 + ) : ( 333 + <a 334 + href="/login" 335 + className="flex items-center justify-center w-11 h-11 rounded-2xl bg-primary-600 text-white shadow-md active:scale-95 transition-transform" 336 + onClick={closeMenu} 337 + > 338 + <User size={18} strokeWidth={2} /> 339 + </a> 340 + )} 285 341 286 - <a 287 - href="/notifications" 288 - className={`relative flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 342 + {isAuthenticated ? ( 343 + <a 344 + href="/notifications" 345 + className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 relative transition-colors" 346 + onClick={(e) => { 347 + if (onNavigate) { 348 + e.preventDefault(); 349 + onNavigate("/notifications"); 350 + } 351 + setCurrentPath("/notifications"); 352 + closeMenu(); 353 + }} 354 + > 355 + <div 356 + className={`p-2 rounded-xl transition-colors relative ${ 289 357 isActive("/notifications") 290 - ? "text-primary-600" 291 - : "text-surface-500 hover:text-surface-700" 358 + ? "bg-primary-50 dark:bg-primary-950/50 text-primary-600 dark:text-primary-400" 359 + : "text-surface-400 dark:text-surface-500" 292 360 }`} 293 - onClick={(e) => { 294 - if (onNavigate) { 295 - e.preventDefault(); 296 - onNavigate("/notifications"); 297 - } 298 - setCurrentPath("/notifications"); 299 - closeMenu(); 300 - }} 301 361 > 302 - <Bell size={24} strokeWidth={1.5} /> 362 + <Bell 363 + size={22} 364 + strokeWidth={isActive("/notifications") ? 2 : 1.5} 365 + /> 303 366 {unreadCount > 0 && ( 304 - <span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full" /> 367 + <span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full ring-2 ring-white dark:ring-surface-900" /> 305 368 )} 306 - </a> 307 - </> 369 + </div> 370 + </a> 308 371 ) : ( 309 - <a 310 - href="/login" 311 - className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 312 - onClick={closeMenu} 313 - > 314 - <User size={20} strokeWidth={2} /> 315 - </a> 372 + <div className="w-14" /> 316 373 )} 317 374 318 375 <button 319 - className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 320 - isMenuOpen 321 - ? "text-primary-600" 322 - : "text-surface-500 hover:text-surface-700" 323 - }`} 376 + className="flex flex-col items-center justify-center w-14 h-14 gap-0.5 transition-colors" 324 377 onClick={() => setIsMenuOpen(!isMenuOpen)} 325 378 > 326 - {isMenuOpen ? ( 327 - <X size={24} strokeWidth={1.5} /> 328 - ) : ( 329 - <MoreHorizontal size={24} strokeWidth={1.5} /> 330 - )} 379 + <div 380 + className={`p-2 rounded-xl transition-colors ${ 381 + isMenuOpen 382 + ? "bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-200" 383 + : "text-surface-400 dark:text-surface-500" 384 + }`} 385 + > 386 + {isMenuOpen ? ( 387 + <X size={22} strokeWidth={1.5} /> 388 + ) : ( 389 + <svg 390 + width="22" 391 + height="22" 392 + viewBox="0 0 22 22" 393 + fill="none" 394 + xmlns="http://www.w3.org/2000/svg" 395 + > 396 + <circle cx="4" cy="11" r="1.75" fill="currentColor" /> 397 + <circle cx="11" cy="11" r="1.75" fill="currentColor" /> 398 + <circle cx="18" cy="11" r="1.75" fill="currentColor" /> 399 + </svg> 400 + )} 401 + </div> 331 402 </button> 332 403 </nav> 333 404 </>
+2 -1
web/src/components/posthog.astro
··· 7 7 ui_host: uiHost, 8 8 defaults: '2026-01-30', 9 9 person_profiles: 'identified_only', 10 - capture_pageview: 'history_change' 10 + capture_pageview: 'history_change', 11 + persistence: 'memory' 11 12 }) 12 13 } 13 14 </script>
+9
web/src/styles/global.css
··· 59 59 @apply transition-all duration-200 ease-out; 60 60 } 61 61 62 + .no-scrollbar { 63 + scrollbar-width: none; 64 + -ms-overflow-style: none; 65 + } 66 + 67 + .no-scrollbar::-webkit-scrollbar { 68 + display: none; 69 + } 70 + 62 71 .custom-scrollbar::-webkit-scrollbar { 63 72 width: 6px; 64 73 height: 6px;
+2 -2
web/src/views/AppShell.tsx
··· 175 175 176 176 <div className="flex-1 min-w-0 transition-all duration-200"> 177 177 <div className="flex w-full max-w-[1800px] mx-auto"> 178 - <main className="flex-1 w-full min-w-0 py-2 md:py-3"> 179 - <div className="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-20 md:pb-6"> 178 + <main className="flex-1 w-full min-w-0 p-2 md:py-3 md:px-0"> 179 + <div className="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-28 md:pb-6"> 180 180 <Routes> 181 181 <Route 182 182 path="/home"
+46 -46
web/src/views/core/Feed.tsx
··· 149 149 </div> 150 150 )} 151 151 {showTabs && ( 152 - <div className="flex items-center gap-1.5 flex-wrap"> 153 - {filters.map((f) => { 154 - const isActive = 155 - f.id === "all" ? !activeFilter : activeFilter === f.id; 156 - return ( 157 - <button 158 - key={f.id} 159 - onClick={() => handleFilterChange(f.id)} 160 - className={clsx( 161 - "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 162 - isActive 163 - ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 164 - : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 165 - )} 166 - > 167 - {f.icon && <f.icon size={12} />} 168 - {f.label} 169 - </button> 170 - ); 171 - })} 172 - <div className="ml-auto"> 173 - <LayoutToggle className="hidden sm:inline-flex" /> 152 + <div className="flex items-center gap-2"> 153 + <div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1 pb-0.5"> 154 + {filters.map((f) => { 155 + const isActive = 156 + f.id === "all" ? !activeFilter : activeFilter === f.id; 157 + return ( 158 + <button 159 + key={f.id} 160 + onClick={() => handleFilterChange(f.id)} 161 + className={clsx( 162 + "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all shrink-0", 163 + isActive 164 + ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 165 + : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 166 + )} 167 + > 168 + {f.icon && <f.icon size={12} />} 169 + {f.label} 170 + </button> 171 + ); 172 + })} 174 173 </div> 174 + <LayoutToggle className="hidden sm:inline-flex shrink-0" /> 175 175 </div> 176 176 )} 177 177 {!showTabs && user && ( 178 - <div className="flex items-center gap-1.5"> 179 - {[ 180 - { id: "everyone", label: t("feed.everyone"), icon: Users }, 181 - { id: "mine", label: t("feed.mine"), icon: User }, 182 - ].map((f) => { 183 - const isActive = f.id === "mine" ? mineOnly : !mineOnly; 184 - return ( 185 - <button 186 - key={f.id} 187 - onClick={() => setMineOnly(f.id === "mine")} 188 - className={clsx( 189 - "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 190 - isActive 191 - ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 192 - : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 193 - )} 194 - > 195 - <f.icon size={12} /> 196 - {f.label} 197 - </button> 198 - ); 199 - })} 200 - <div className="ml-auto"> 201 - <LayoutToggle className="hidden sm:inline-flex" /> 178 + <div className="flex items-center gap-2"> 179 + <div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1 pb-0.5"> 180 + {[ 181 + { id: "everyone", label: t("feed.everyone"), icon: Users }, 182 + { id: "mine", label: t("feed.mine"), icon: User }, 183 + ].map((f) => { 184 + const isActive = f.id === "mine" ? mineOnly : !mineOnly; 185 + return ( 186 + <button 187 + key={f.id} 188 + onClick={() => setMineOnly(f.id === "mine")} 189 + className={clsx( 190 + "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all shrink-0", 191 + isActive 192 + ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 193 + : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 194 + )} 195 + > 196 + <f.icon size={12} /> 197 + {f.label} 198 + </button> 199 + ); 200 + })} 202 201 </div> 202 + <LayoutToggle className="hidden sm:inline-flex shrink-0" /> 203 203 </div> 204 204 )} 205 205 </div>
+8 -6
web/src/views/core/Settings.tsx
··· 225 225 ))} 226 226 </div> 227 227 228 - <div className="mt-6 flex items-center justify-between"> 229 - <div> 228 + <div className="mt-6 flex items-center justify-between gap-4"> 229 + <div className="min-w-0 flex-1"> 230 230 <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 231 231 {t("settings.appearance.disableExternalLinkWarning")} 232 232 </h3> ··· 237 237 <Switch 238 238 checked={preferences.disableExternalLinkWarning} 239 239 onCheckedChange={setDisableExternalLinkWarning} 240 + className="shrink-0" 240 241 /> 241 242 </div> 242 243 243 - <div className="mt-6 flex items-center justify-between"> 244 - <div> 244 + <div className="mt-6 flex items-center justify-between gap-4"> 245 + <div className="min-w-0 flex-1"> 245 246 <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 246 247 {t("settings.appearance.communityBookmarks")} 247 248 </h3> ··· 252 253 <Switch 253 254 checked={preferences.enableCommunityBookmarks} 254 255 onCheckedChange={setEnableCommunityBookmarks} 256 + className="shrink-0" 255 257 /> 256 258 </div> 257 259 </section> ··· 685 687 return ( 686 688 <div 687 689 key={label} 688 - className="flex items-center justify-between py-1.5" 690 + className="flex items-center justify-between gap-2 py-1.5" 689 691 > 690 - <span className="text-sm text-surface-600 dark:text-surface-400"> 692 + <span className="text-sm text-surface-600 dark:text-surface-400 min-w-0 flex-1"> 691 693 {t(`card.labelDescriptions.${label}`)} 692 694 </span> 693 695 <div className="flex gap-1">