From 92baa9a63c4ed5864a19a77ee6100a3576d81878 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Tue, 28 Apr 2026 04:23:47 -0800 Subject: [PATCH] mobile enhancements --- web/src/components/common/Card.tsx | 19 +- web/src/components/common/MoreMenu.tsx | 109 +++- web/src/components/feed/Composer.tsx | 18 +- .../modals/AddToCollectionModal.tsx | 142 +++-- web/src/components/modals/ShareMenu.tsx | 241 ++++++--- web/src/components/navigation/MobileNav.tsx | 483 ++++++++++-------- web/src/components/posthog.astro | 3 +- web/src/styles/global.css | 9 + web/src/views/AppShell.tsx | 4 +- web/src/views/core/Feed.tsx | 92 ++-- web/src/views/core/Settings.tsx | 14 +- 11 files changed, 719 insertions(+), 415 deletions(-) diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index d5b2cfc..9a5b444 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -360,7 +360,7 @@ export default function Card({ const displayImage = ogData?.image; return ( -
+
{!hideCollection && (item.collection || (item.context && item.context.length > 0)) && (
@@ -419,7 +419,7 @@ export default function Card({
)} -
+
@@ -442,16 +442,19 @@ export default function Card({
-
- +
+ {item.author?.displayName || item.author?.handle} - + @{item.author?.handle} ยท @@ -611,7 +614,7 @@ export default function Card({ "shrink-0 bg-surface-200 dark:bg-surface-700 relative", layout === "mosaic" ? "w-full aspect-video border-b border-surface-200 dark:border-surface-700" - : "w-[140px] sm:w-[180px] border-r border-surface-200 dark:border-surface-700", + : "w-[90px] sm:w-[140px] border-r border-surface-200 dark:border-surface-700", )} >
@@ -653,7 +656,7 @@ export default function Card({ )}
- + {displayUrl || pageUrl}
diff --git a/web/src/components/common/MoreMenu.tsx b/web/src/components/common/MoreMenu.tsx index 5cecbe5..10154c0 100644 --- a/web/src/components/common/MoreMenu.tsx +++ b/web/src/components/common/MoreMenu.tsx @@ -1,5 +1,5 @@ -import React, { useState, useRef, useEffect } from "react"; -import { MoreHorizontal } from "lucide-react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { MoreHorizontal, X } from "lucide-react"; import { clsx } from "clsx"; export interface MoreMenuItem { @@ -17,11 +17,22 @@ interface MoreMenuProps { export default function MoreMenu({ items, className }: MoreMenuProps) { const [isOpen, setIsOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); const buttonRef = useRef(null); const menuRef = useRef(null); + const sheetRef = useRef(null); + const dragStartY = useRef(0); + const dragCurrentY = useRef(0); useEffect(() => { - if (!isOpen) return; + const check = () => setIsMobile(window.innerWidth < 640); + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); + + useEffect(() => { + if (!isOpen || isMobile) return; const handleClickOutside = (e: MouseEvent) => { if ( @@ -48,7 +59,33 @@ export default function MoreMenu({ items, className }: MoreMenuProps) { document.removeEventListener("scroll", handleScroll, true); document.removeEventListener("keydown", handleEscape); }; - }, [isOpen]); + }, [isOpen, isMobile]); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + dragStartY.current = e.touches[0].clientY; + if (sheetRef.current) sheetRef.current.style.transition = "none"; + }, []); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + const delta = e.touches[0].clientY - dragStartY.current; + dragCurrentY.current = delta; + if (delta > 0 && sheetRef.current) { + sheetRef.current.style.transform = `translateY(${delta}px)`; + } + }, []); + + const handleTouchEnd = useCallback(() => { + if (sheetRef.current) { + sheetRef.current.style.transition = "transform 0.3s ease"; + if (dragCurrentY.current > 100) { + sheetRef.current.style.transform = "translateY(100%)"; + setTimeout(() => setIsOpen(false), 300); + } else { + sheetRef.current.style.transform = "translateY(0)"; + } + } + dragCurrentY.current = 0; + }, []); if (items.length === 0) return null; @@ -63,7 +100,69 @@ export default function MoreMenu({ items, className }: MoreMenuProps) { - {isOpen && ( + {isOpen && isMobile && ( + <> +
setIsOpen(false)} + /> +
+
+
+
+
+
+ + Options + + +
+
+ {items.map((item, i) => ( + + ))} +
+
+
+ + )} + + {isOpen && !isMobile && (
-
-

+
+

{url && ( -
- {url} +
+ {(() => { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } + })()}
)}
@@ -309,7 +315,7 @@ export default function Composer({ )}

-
+
2900 @@ -319,7 +325,7 @@ export default function Composer({ > {text.length}/3000 -
+
{onCancel && (
-
+
{loading ? (
)} -
+
) : ( -
+
{error && ( -
+
{error}
)} +
+ {collections.length === 0 ? ( -
-

+

+

{t("addToCollection.none")}

) : ( -
+
{collections.map((col) => { const isAdded = addedTo.has(col.uri); const isAdding = addingTo === col.uri; @@ -370,32 +411,28 @@ export default function AddToCollectionModal({ key={col.uri} onClick={() => handleAdd(col.uri)} disabled={isAdding || isAdded} - 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" + 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" > -
- -
-
-

- {col.name} -

- {col.description && ( -

- {col.description} -

- )} -
+ + + + + {col.name} + {isAdding ? ( ) : isAdded ? ( - + ) : ( )} @@ -403,13 +440,6 @@ export default function AddToCollectionModal({ })}
)} - -
)}
diff --git a/web/src/components/modals/ShareMenu.tsx b/web/src/components/modals/ShareMenu.tsx index 832b884..16035db 100644 --- a/web/src/components/modals/ShareMenu.tsx +++ b/web/src/components/modals/ShareMenu.tsx @@ -1,10 +1,11 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { Copy, ExternalLink, Check, Share2, MoreHorizontal, + X, } from "lucide-react"; import { AturiIcon, @@ -43,14 +44,51 @@ export default function ShareMenu({ const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [copied, setCopied] = useState(null); + const [isMobile, setIsMobile] = useState(false); const menuRef = useRef(null); const buttonRef = useRef(null); + const sheetRef = useRef(null); + const dragStartY = useRef(0); + const dragCurrentY = useRef(0); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + dragStartY.current = e.touches[0].clientY; + if (sheetRef.current) sheetRef.current.style.transition = "none"; + }, []); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + const delta = e.touches[0].clientY - dragStartY.current; + dragCurrentY.current = delta; + if (delta > 0 && sheetRef.current) { + sheetRef.current.style.transform = `translateY(${delta}px)`; + } + }, []); + + const handleTouchEnd = useCallback(() => { + if (sheetRef.current) { + sheetRef.current.style.transition = "transform 0.3s ease"; + if (dragCurrentY.current > 100) { + sheetRef.current.style.transform = "translateY(100%)"; + setTimeout(() => setIsOpen(false), 300); + } else { + sheetRef.current.style.transform = "translateY(0)"; + } + } + dragCurrentY.current = 0; + }, []); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, alignRight: false, }); + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 640); + check(); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); + const getShareUrl = () => { if (customUrl) return customUrl; if (!uri) return ""; @@ -138,7 +176,7 @@ export default function ShareMenu({ setIsOpen(false); } }; - if (isOpen) { + if (isOpen && !isMobile) { document.addEventListener("mousedown", handleClickOutside); window.addEventListener("scroll", () => setIsOpen(false), true); window.addEventListener("resize", () => setIsOpen(false)); @@ -148,22 +186,28 @@ export default function ShareMenu({ window.removeEventListener("scroll", () => setIsOpen(false), true); window.removeEventListener("resize", () => setIsOpen(false)); }; - }, [isOpen]); + }, [isOpen, isMobile]); const calculatePosition = () => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); - const menuWidth = 240; + const menuWidth = 260; + const padding = 8; let top = rect.bottom + 8; let left = rect.left; let alignRight = false; - if (left + menuWidth > window.innerWidth - 16) { + if (left + menuWidth > window.innerWidth - padding) { left = rect.right - menuWidth; alignRight = true; } + left = Math.max( + padding, + Math.min(left, window.innerWidth - menuWidth - padding), + ); + if (top + 300 > window.innerHeight) { top = rect.top - 8; } @@ -172,7 +216,7 @@ export default function ShareMenu({ }; const toggleMenu = () => { - if (!isOpen) calculatePosition(); + if (!isOpen && !isMobile) calculatePosition(); setIsOpen(!isOpen); }; @@ -227,6 +271,79 @@ export default function ShareMenu({ { name: "Deer", domain: "deer.social", icon: }, ]; + const menuContent = ( +
+ {isSemble ? ( + <> +
+ + {t("shareMenu.sembleIntegration")} +
+ {renderMenuItem( + t("shareMenu.openOnSemble"), + , + () => window.open(sembleUrl, "_blank"), + false, + true, + )} + {renderMenuItem( + t("shareMenu.copySembleLink"), + , + () => handleCopy(sembleUrl, "semble"), + copied === "semble", + )} +
+ + ) : null} + + {renderMenuItem( + t("shareMenu.copyLink"), + , + () => handleCopy(shareUrl, "link"), + copied === "link", + )} + +
+ {t("shareMenu.shareViaApp")} +
+ +
+ {shareForks.map((fork) => ( + + ))} +
+ +
+ + {renderMenuItem( + t("shareMenu.copyUniversalLink"), + , + () => handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), + copied === "aturi", + )} + + {typeof navigator !== "undefined" && + navigator.share && + renderMenuItem( + t("shareMenu.moreOptions"), + , + () => { + navigator + .share({ title: "Margin", text, url: shareUrl }) + .catch(() => {}); + setIsOpen(false); + }, + )} +
+ ); + return (
- {isOpen && ( + {isOpen && isMobile && ( + <> +
setIsOpen(false)} + /> +
+
+
+
+
+
+ + {t("shareMenu.share", { defaultValue: "Share" })} + + +
+
{menuContent}
+
+
+ + )} + {isOpen && !isMobile && (
-
- {isSemble ? ( - <> -
- - {t("shareMenu.sembleIntegration")} -
- {renderMenuItem( - t("shareMenu.openOnSemble"), - , - () => window.open(sembleUrl, "_blank"), - false, - true, - )} - {renderMenuItem( - t("shareMenu.copySembleLink"), - , - () => handleCopy(sembleUrl, "semble"), - copied === "semble", - )} -
- - ) : null} - - {renderMenuItem( - t("shareMenu.copyLink"), - , - () => handleCopy(shareUrl, "link"), - copied === "link", - )} - -
- {t("shareMenu.shareViaApp")} -
- -
- {shareForks.map((fork) => ( - - ))} -
- -
- - {renderMenuItem( - t("shareMenu.copyUniversalLink"), - , - () => - handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), - copied === "aturi", - )} - - {typeof navigator !== "undefined" && - navigator.share && - renderMenuItem( - t("shareMenu.moreOptions"), - , - () => { - navigator - .share({ title: "Margin", text, url: shareUrl }) - .catch(() => {}); - setIsOpen(false); - }, - )} -
+ {menuContent}
)}
diff --git a/web/src/components/navigation/MobileNav.tsx b/web/src/components/navigation/MobileNav.tsx index a942731..5dce832 100644 --- a/web/src/components/navigation/MobileNav.tsx +++ b/web/src/components/navigation/MobileNav.tsx @@ -7,14 +7,13 @@ import { Home, LogOut, MessageSquareText, - MoreHorizontal, PenSquare, Search, Settings, User, X, } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { getUnreadNotificationCount } from "../../api/client"; import { $user, logout } from "../../store/auth"; import { AppleIcon } from "../common/Icons"; @@ -56,185 +55,222 @@ export default function MobileNav({ <> {isMenuOpen && (
)} {isMenuOpen && ( -
-
- {isAuthenticated && user ? ( - <> - { - if (onNavigate) { - e.preventDefault(); - onNavigate(`/profile/${user.did}`); - } - closeMenu(); - }} - > - {user.avatar ? ( - - ) : ( -
- -
- )} -
- - {user.displayName || user.handle} - - - @{user.handle} - -
-
- -
+
+
+
+
+
- {[ - { - href: "/annotations", - icon: MessageSquareText, - label: t("nav.annotations"), - }, - { - href: "/highlights", - icon: Highlighter, - label: t("nav.highlights"), - }, - { - href: "/bookmarks", - icon: Bookmark, - label: t("nav.bookmarks"), - }, - { - href: "/collections", - icon: Folder, - label: t("nav.collections"), - }, - { - href: "/settings", - icon: Settings, - label: t("nav.settings"), - }, - ].map(({ href, icon: Icon, label }) => ( +
+ {isAuthenticated && user ? ( + <> { if (onNavigate) { e.preventDefault(); - onNavigate(href); + onNavigate(`/profile/${user.did}`); } closeMenu(); }} > - - {label} + {user.avatar ? ( + + ) : ( +
+ +
+ )} +
+ + {user.displayName || user.handle} + + + @{user.handle} + +
- ))} -
+
- - - {t("mobileNav.iosShortcut")} - +
+ {[ + { + href: "/annotations", + icon: MessageSquareText, + label: t("nav.annotations"), + }, + { + href: "/highlights", + icon: Highlighter, + label: t("nav.highlights"), + }, + { + href: "/bookmarks", + icon: Bookmark, + label: t("nav.bookmarks"), + }, + { + href: "/collections", + icon: Folder, + label: t("nav.collections"), + }, + ].map(({ href, icon: Icon, label }) => ( + { + if (onNavigate) { + e.preventDefault(); + onNavigate(href); + } + closeMenu(); + }} + > + + + {label} + + + ))} +
-
+
- - - ) : ( - <> - - - {t("nav.signIn")} - - {[ - { - href: "/collections", - icon: Folder, - label: t("nav.collections"), - }, - { - href: "/settings", - icon: Settings, - label: t("nav.settings"), - }, - ].map(({ href, icon: Icon, label }) => ( - { - if (onNavigate) { - e.preventDefault(); - onNavigate(href); - } + + +
+ + + + ) : ( + <> + + + + {t("nav.signIn")} + - ))} + {[ + { + href: "/collections", + icon: Folder, + label: t("nav.collections"), + }, + { + href: "/settings", + icon: Settings, + label: t("nav.settings"), + }, + ].map(({ href, icon: Icon, label }) => ( + { + if (onNavigate) { + e.preventDefault(); + onNavigate(href); + } + closeMenu(); + }} + > + + {label} + + ))} -
)} -