diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 7c10ef6..f057eaa 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -64,6 +64,48 @@ const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; type ThemePreference = 'system' | 'light' | 'dark'; +const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab'; + +function installMagneticUiFeedback() { + if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}; + if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}; + if (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches) return () => {}; + + const resetNode = (node: HTMLElement) => { + node.style.setProperty('--mag-x', '0px'); + node.style.setProperty('--mag-y', '0px'); + node.style.removeProperty('--mx'); + node.style.removeProperty('--my'); + }; + + const onPointerMove = (event: PointerEvent) => { + const node = event.target instanceof Element ? event.target.closest(MAGNETIC_SELECTOR) : null; + if (!node) return; + const rect = node.getBoundingClientRect(); + const localX = event.clientX - rect.left; + const localY = event.clientY - rect.top; + const dx = (localX - rect.width / 2) / Math.max(rect.width / 2, 1); + const dy = (localY - rect.height / 2) / Math.max(rect.height / 2, 1); + node.style.setProperty('--mx', `${localX}px`); + node.style.setProperty('--my', `${localY}px`); + node.style.setProperty('--mag-x', `${dx * 6}px`); + node.style.setProperty('--mag-y', `${dy * 4}px`); + }; + + const onPointerLeave = (event: Event) => { + const node = event.target instanceof Element ? event.target.closest(MAGNETIC_SELECTOR) : null; + if (!node) return; + resetNode(node); + }; + + document.addEventListener('pointermove', onPointerMove, { passive: true }); + document.addEventListener('pointerleave', onPointerLeave, true); + + return () => { + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerleave', onPointerLeave, true); + }; +} function readThemePreference(): ThemePreference { if (typeof window === 'undefined') return 'system'; @@ -218,6 +260,8 @@ export default function App() { window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); }, [themePreference]); + useEffect(() => installMagneticUiFeedback(), []); + function handleToggleTheme() { setThemePreference((prev) => { const current = prev === 'system' ? systemTheme : prev; diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index 5049a9c..48c4a9e 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -25,6 +25,8 @@ interface AppAuthenticatedShellProps { } export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { + const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location; + return (
@@ -106,7 +108,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
- +
+ +
diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index 3bc02f2..4f0d0be 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { t } from '@/lib/i18n'; @@ -19,14 +20,32 @@ interface ConfirmDialogProps { } export default function ConfirmDialog(props: ConfirmDialogProps) { - if (!props.open) return null; + const [present, setPresent] = useState(props.open); + const [closing, setClosing] = useState(false); + + useEffect(() => { + if (props.open) { + setPresent(true); + setClosing(false); + return; + } + if (!present) return; + setClosing(true); + const timer = window.setTimeout(() => { + setPresent(false); + setClosing(false); + }, 240); + return () => window.clearTimeout(timer); + }, [props.open, present]); + + if (!present) return null; return ( -
+
{ e.preventDefault(); - if (props.confirmDisabled) return; + if (props.confirmDisabled || closing) return; props.onConfirm(); }} > diff --git a/webapp/src/components/SendsPage.tsx b/webapp/src/components/SendsPage.tsx index 9d506d8..029e2a5 100644 --- a/webapp/src/components/SendsPage.tsx +++ b/webapp/src/components/SendsPage.tsx @@ -62,6 +62,10 @@ function draftFromSend(send: Send): SendDraft { } export default function SendsPage(props: SendsPageProps) { + const getInitialIsMobileLayout = () => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia(MOBILE_LAYOUT_QUERY).matches + : false; const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); const [selectedId, setSelectedId] = useState(null); @@ -71,7 +75,7 @@ export default function SendsPage(props: SendsPageProps) { const [draft, setDraft] = useState(null); const [showPassword, setShowPassword] = useState(false); const [selectedMap, setSelectedMap] = useState>({}); - const [isMobileLayout, setIsMobileLayout] = useState(false); + const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout); const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [autoCopyLink, setAutoCopyLink] = useState(() => { @@ -226,7 +230,15 @@ export default function SendsPage(props: SendsPageProps) { return (
- {isMobileLayout && mobileSidebarOpen &&
setMobileSidebarOpen(false)} />} + {isMobileLayout && ( +
{ + if (!mobileSidebarOpen) return; + setMobileSidebarOpen(false); + }} + /> + )}