diff --git a/apps/web/src/components/mobile-not-supported.tsx b/apps/web/src/components/mobile-not-supported.tsx index 0b136f5c4..0201e25c2 100644 --- a/apps/web/src/components/mobile-not-supported.tsx +++ b/apps/web/src/components/mobile-not-supported.tsx @@ -2,20 +2,19 @@ import { Smartphone } from 'lucide-react'; export const MobileNotSupported = () => { return ( -
-
+
+
-

Mobile not supported

+

+ Phone version coming soon +

- Hey there! Thanks for checking out Brainbox. + Brainbox works best on tablets and desktops. A native phone app is in + development and will be available soon.

- Right now, Brainbox is not quite ready for mobile devices just yet. - For the best experience, please hop onto a desktop or laptop. We're - working hard to bring you an awesome mobile experience soon. -

-

- Thanks for your patience and support! + In the meantime, please use a tablet or desktop browser for the best + experience.

diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 03a51e9f0..1f318b8b6 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -17,9 +17,20 @@ export const isOpfsSupported = async (): Promise => { } }; -const mobileDeviceRegex = - /Android|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i; +// Phone detection - blocks phones, allows tablets +const phoneDeviceRegex = /iPhone|iPod|Opera Mini|IEMobile|WPDesktop/i; -export const isMobileDevice = (): boolean => { - return mobileDeviceRegex.test(navigator.userAgent); +// Android phone detection (excludes tablets) +const isAndroidPhone = (): boolean => { + const ua = navigator.userAgent; + // Android tablets typically have "Tablet" or larger screen identifiers + // Android phones have "Mobile" in the user agent + return /Android/i.test(ua) && /Mobile/i.test(ua); }; + +export const isPhoneDevice = (): boolean => { + return phoneDeviceRegex.test(navigator.userAgent) || isAndroidPhone(); +}; + +// Keep for backwards compatibility, but deprecate +export const isMobileDevice = isPhoneDevice; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f5ebb983a..2b76fb257 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,13 +5,13 @@ import { eventBus } from '@brainbox/client/lib'; import { BrowserNotSupported } from '@brainbox/web/components/browser-not-supported'; import { MobileNotSupported } from '@brainbox/web/components/mobile-not-supported'; import { ColanodeWorkerApi } from '@brainbox/web/lib/types'; -import { isMobileDevice, isOpfsSupported } from '@brainbox/web/lib/utils'; +import { isPhoneDevice, isOpfsSupported } from '@brainbox/web/lib/utils'; import { Root } from '@brainbox/web/root'; import DedicatedWorker from '@brainbox/web/workers/dedicated?worker'; const initializeApp = async () => { - const isMobile = isMobileDevice(); - if (isMobile) { + const isPhone = isPhoneDevice(); + if (isPhone) { const root = createRoot(document.getElementById('root') as HTMLElement); root.render(); return; diff --git a/package-lock.json b/package-lock.json index fe2936008..3fecbf345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22206,6 +22206,16 @@ "dnd-core": "^16.0.1" } }, + "node_modules/react-dnd-touch-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz", + "integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -27098,6 +27108,7 @@ "react-day-picker": "^9.8.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", + "react-dnd-touch-backend": "^16.0.1", "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", "react-intersection-observer": "^9.16.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index c1f1e6dea..f36fd4aa5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -73,8 +73,8 @@ "date-fns": "^4.1.0", "framer-motion": "^12.23.26", "is-hotkey": "^0.2.0", - "markdown-it": "^14.1.0", "lucide-react": "^0.539.0", + "markdown-it": "^14.1.0", "prosemirror-markdown": "^1.13.1", "re-resizable": "^6.11.2", "react": "^19.1.1", @@ -82,6 +82,7 @@ "react-day-picker": "^9.8.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", + "react-dnd-touch-backend": "^16.0.1", "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", "react-intersection-observer": "^9.16.0", diff --git a/packages/ui/src/components/databases/tables/table-view-field-header.tsx b/packages/ui/src/components/databases/tables/table-view-field-header.tsx index c9cc20883..c73f826ce 100644 --- a/packages/ui/src/components/databases/tables/table-view-field-header.tsx +++ b/packages/ui/src/components/databases/tables/table-view-field-header.tsx @@ -33,6 +33,7 @@ import { import { Separator } from '@brainbox/ui/components/ui/separator'; import { useDatabase } from '@brainbox/ui/contexts/database'; import { useDatabaseView } from '@brainbox/ui/contexts/database-view'; +import { useDevice } from '@brainbox/ui/hooks/use-device'; import { isFilterableField, isSortableField } from '@brainbox/ui/lib/databases'; import { cn } from '@brainbox/ui/lib/utils'; @@ -46,6 +47,7 @@ export const TableViewFieldHeader = memo( ({ viewField }: TableViewFieldHeaderProps) => { const database = useDatabase(); const view = useDatabaseView(); + const { isTouch } = useDevice(); // Find current sort for this field const currentSort = view.sorts.find( @@ -327,16 +329,15 @@ export const TableViewFieldHeader = memo( }} handleClasses={{ right: cn( - // Wider invisible hit area; avoid adding any extra border line - 'bg-transparent transition-colors duration-150', - // Only show subtle background while actively resizing to indicate grip + 'transition-colors duration-150', + isTouch ? 'bg-border/50' : 'bg-transparent', isResizing && 'bg-primary/50' ), }} handleStyles={{ right: { - width: '8px', - right: '-4px', + width: isTouch ? '24px' : '8px', + right: isTouch ? '-12px' : '-4px', top: '0px', height: '100%', cursor: 'col-resize', diff --git a/packages/ui/src/components/layouts/layout.tsx b/packages/ui/src/components/layouts/layout.tsx index f7ed1d67d..e56eaee16 100644 --- a/packages/ui/src/components/layouts/layout.tsx +++ b/packages/ui/src/components/layouts/layout.tsx @@ -15,6 +15,7 @@ import { SettingsDialog } from '@brainbox/ui/components/settings/settings-dialog import { LayoutContext } from '@brainbox/ui/contexts/layout'; import { useServer } from '@brainbox/ui/contexts/server'; import { useWorkspace } from '@brainbox/ui/contexts/workspace'; +import { useDevice } from '@brainbox/ui/hooks/use-device'; import { useLayoutState } from '@brainbox/ui/hooks/use-layout-state'; import { useLiveQuery } from '@brainbox/ui/hooks/use-live-query'; import { useWindowSize } from '@brainbox/ui/hooks/use-window-size'; @@ -24,6 +25,8 @@ export const Layout = () => { const server = useServer(); const workspace = useWorkspace(); const windowSize = useWindowSize(); + const { isTablet, isTouch } = useDevice(); + const useTouchHandles = isTablet || isTouch; const [showShortcuts, setShowShortcuts] = useState(false); const [showSearch, setShowSearch] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -69,7 +72,7 @@ export const Layout = () => { !server.isOutdated && leftContainerMetadata.tabs.length > 0; const shouldDisplayRight = - !server.isOutdated && rightContainerMetadata.tabs.length > 0; + !server.isOutdated && rightContainerMetadata.tabs.length > 0 && !isTablet; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -91,6 +94,12 @@ export const Layout = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [handleSidebarToggle]); + useEffect(() => { + if (isTablet && !sidebarMetadata.collapsed) { + handleSidebarToggle(); + } + }, [isTablet]); + return ( { topRight: false, }} handleClasses={{ - right: 'opacity-0 hover:opacity-100 bg-blue-300 z-30', + right: useTouchHandles + ? 'opacity-100 bg-border z-30' + : 'opacity-0 hover:opacity-100 bg-blue-300 z-30', }} handleStyles={{ right: { - width: '3px', - right: '-3px', + width: useTouchHandles ? '12px' : '3px', + right: useTouchHandles ? '-6px' : '-3px', }, }} onResize={(_, __, ref) => { @@ -188,12 +199,14 @@ export const Layout = () => { topRight: false, }} handleClasses={{ - left: 'opacity-0 hover:opacity-100 bg-blue-300 z-30', + left: useTouchHandles + ? 'opacity-100 bg-border z-30' + : 'opacity-0 hover:opacity-100 bg-blue-300 z-30', }} handleStyles={{ left: { - width: '3px', - left: '-3px', + width: useTouchHandles ? '12px' : '3px', + left: useTouchHandles ? '-6px' : '-3px', }, }} onResize={(_, __, ref) => { diff --git a/packages/ui/src/components/layouts/sidebars/right-sidebar.tsx b/packages/ui/src/components/layouts/sidebars/right-sidebar.tsx index c610f3853..76d8e8ba2 100644 --- a/packages/ui/src/components/layouts/sidebars/right-sidebar.tsx +++ b/packages/ui/src/components/layouts/sidebars/right-sidebar.tsx @@ -17,6 +17,7 @@ import { useAccount } from '@brainbox/ui/contexts/account'; import { useLayout } from '@brainbox/ui/contexts/layout'; import { useRadar } from '@brainbox/ui/contexts/radar'; import { useWorkspace } from '@brainbox/ui/contexts/workspace'; +import { useDevice } from '@brainbox/ui/hooks/use-device'; import { useLiveQuery } from '@brainbox/ui/hooks/use-live-query'; import { useMutation } from '@brainbox/ui/hooks/use-mutation'; import { usePinnedItems } from '@brainbox/ui/hooks/use-pinned-items'; @@ -48,6 +49,8 @@ export const RightSidebar = ({ onOpenShortcuts }: RightSidebarProps = {}) => { const radar = useRadar(); const { mutate } = useMutation(); const { pinnedItems } = usePinnedItems(); + const { isTablet, isTouch } = useDevice(); + const useLargeTargets = isTablet || isTouch; const channelsQuery = useLiveQuery({ type: 'node.children.get', @@ -578,7 +581,12 @@ export const RightSidebar = ({ onOpenShortcuts }: RightSidebarProps = {}) => {
{/* Icon Bar */} -
+
{ shortcut="⌥C" isActive={activePanel === 'channels'} unreadCount={unreadChannelCount} + useLargeTargets={useLargeTargets} onClick={() => handleIconClick('channels')} /> { shortcut="⌥D" isActive={activePanel === 'chats'} unreadCount={unreadChatCount} + useLargeTargets={useLargeTargets} onClick={() => handleIconClick('chats')} />
@@ -627,6 +637,7 @@ interface IconBarButtonProps { shortcut: string; isActive: boolean; unreadCount: number; + useLargeTargets?: boolean; onClick: () => void; } @@ -636,6 +647,7 @@ function IconBarButton({ shortcut, isActive, unreadCount, + useLargeTargets = false, onClick, }: IconBarButtonProps) { return ( @@ -644,12 +656,13 @@ function IconBarButton({
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 4a24b5d08..d3372e64e 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,4 +1,6 @@ export { useBoardDragDrop } from './use-board-drag-drop'; export { useDebounce } from './use-debounce'; +export { useDevice } from './use-device'; export { useGlobalShortcut } from './use-global-shortcut'; +export { useLongPress } from './use-long-press'; export { usePinnedItems } from './use-pinned-items'; diff --git a/packages/ui/src/hooks/use-device.tsx b/packages/ui/src/hooks/use-device.tsx new file mode 100644 index 000000000..622a120fc --- /dev/null +++ b/packages/ui/src/hooks/use-device.tsx @@ -0,0 +1,67 @@ +import { useSyncExternalStore } from 'react'; + +type DeviceType = 'mobile' | 'tablet' | 'desktop'; + +interface DeviceInfo { + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isTouch: boolean; + deviceType: DeviceType; + width: number; +} + +const BREAKPOINTS = { + tablet: 640, + desktop: 1024, +} as const; + +function getDeviceInfo(): DeviceInfo { + const width = window.innerWidth; + const isMobile = width < BREAKPOINTS.tablet; + const isTablet = width >= BREAKPOINTS.tablet && width < BREAKPOINTS.desktop; + const isDesktop = width >= BREAKPOINTS.desktop; + const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + return { + isMobile, + isTablet, + isDesktop, + isTouch, + deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop', + width, + }; +} + +let cachedDeviceInfo: DeviceInfo | null = null; + +function subscribe(callback: () => void): () => void { + const handleResize = () => { + cachedDeviceInfo = null; + callback(); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); +} + +function getSnapshot(): DeviceInfo { + if (!cachedDeviceInfo) { + cachedDeviceInfo = getDeviceInfo(); + } + return cachedDeviceInfo; +} + +function getServerSnapshot(): DeviceInfo { + return { + isMobile: false, + isTablet: false, + isDesktop: true, + isTouch: false, + deviceType: 'desktop', + width: 1920, + }; +} + +export function useDevice(): DeviceInfo { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/packages/ui/src/hooks/use-layout-state.tsx b/packages/ui/src/hooks/use-layout-state.tsx index 6c575942e..353805490 100644 --- a/packages/ui/src/hooks/use-layout-state.tsx +++ b/packages/ui/src/hooks/use-layout-state.tsx @@ -6,12 +6,14 @@ import { SidebarMetadata, } from '@brainbox/client/types'; import { useWorkspace } from '@brainbox/ui/contexts/workspace'; +import { useDevice } from '@brainbox/ui/hooks/use-device'; import { useWindowSize } from '@brainbox/ui/hooks/use-window-size'; import { percentToNumber } from '@brainbox/ui/lib/utils'; export const useLayoutState = () => { const workspace = useWorkspace(); const windowSize = useWindowSize(); + const { isTablet } = useDevice(); const [activeContainer, setActiveContainer] = useState<'left' | 'right'>( 'left' @@ -184,13 +186,13 @@ export const useLayoutState = () => { const handleOpen = useCallback( (path: string) => { - if (activeContainer === 'left') { + if (isTablet || activeContainer === 'left') { handleOpenLeft(path); } else { handleOpenRight(path); } }, - [activeContainer, handleOpenLeft, handleOpenRight] + [isTablet, activeContainer, handleOpenLeft, handleOpenRight] ); const handleCloseLeft = useCallback( @@ -362,13 +364,13 @@ export const useLayoutState = () => { const handlePreview = useCallback( (path: string, keepCurrent: boolean = false) => { - if (activeContainer === 'left') { + if (isTablet || activeContainer === 'left') { handlePreviewLeft(path, keepCurrent); } else { handlePreviewRight(path, keepCurrent); } }, - [activeContainer, handlePreviewLeft, handlePreviewRight] + [isTablet, activeContainer, handlePreviewLeft, handlePreviewRight] ); const handleActivateLeft = useCallback( diff --git a/packages/ui/src/hooks/use-long-press.tsx b/packages/ui/src/hooks/use-long-press.tsx new file mode 100644 index 000000000..782d39aa4 --- /dev/null +++ b/packages/ui/src/hooks/use-long-press.tsx @@ -0,0 +1,56 @@ +import { useCallback, useRef, useState } from 'react'; + +interface UseLongPressOptions { + onLongPress: (event: React.TouchEvent | React.MouseEvent) => void; + onClick?: (event: React.MouseEvent) => void; + delay?: number; +} + +export function useLongPress({ + onLongPress, + onClick, + delay = 500, +}: UseLongPressOptions) { + const [longPressed, setLongPressed] = useState(false); + const timeoutRef = useRef(null); + const targetRef = useRef(null); + + const start = useCallback( + (event: React.TouchEvent | React.MouseEvent) => { + targetRef.current = event.target; + timeoutRef.current = window.setTimeout(() => { + setLongPressed(true); + onLongPress(event); + }, delay); + }, + [onLongPress, delay] + ); + + const clear = useCallback( + (event: React.MouseEvent, shouldClick: boolean = false) => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + if (shouldClick && !longPressed && onClick) { + onClick(event); + } + setLongPressed(false); + }, + [onClick, longPressed] + ); + + const handlers = { + onMouseDown: start, + onMouseUp: (e: React.MouseEvent) => clear(e, true), + onMouseLeave: (e: React.MouseEvent) => clear(e, false), + onTouchStart: start, + onTouchEnd: () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + setLongPressed(false); + }, + }; + + return handlers; +} diff --git a/packages/ui/src/lib/dnd-backend.ts b/packages/ui/src/lib/dnd-backend.ts index b42d16ad0..ab6e60b0d 100644 --- a/packages/ui/src/lib/dnd-backend.ts +++ b/packages/ui/src/lib/dnd-backend.ts @@ -1,25 +1,16 @@ import { HTML5Backend as ReactDndHTML5Backend } from 'react-dnd-html5-backend'; - -// We need to create a modified version of the HTML5Backend that ignores -// events that are part of the ProseMirror editor, because it intercepts -// them and causes issues with the drag and drop in the editor. - -// For more information, see: -// https://github.com/react-dnd/react-dnd/issues/802 +import { TouchBackend } from 'react-dnd-touch-backend'; const shouldIgnoreTarget = (domNode: HTMLElement) => { const hasProseMirror = domNode.closest('.ProseMirror'); if (hasProseMirror) { return !domNode.closest('.react-renderer.node-database'); } - return false; }; -export const HTML5Backend = (...args: unknown[]) => { - // @ts-expect-error - HTML5Backend is not typed - const instance = new ReactDndHTML5Backend(...args); - +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const wrapBackendListeners = (instance: any) => { const listeners = [ 'handleTopDragStart', 'handleTopDragStartCapture', @@ -34,12 +25,24 @@ export const HTML5Backend = (...args: unknown[]) => { ]; listeners.forEach((name) => { const original = instance[name]; - instance[name] = (e: Event, ...extraArgs: unknown[]) => { - if (!shouldIgnoreTarget(e.target as HTMLElement)) { - original(e, ...extraArgs); - } - }; + if (typeof original === 'function') { + instance[name] = (e: Event, ...extraArgs: unknown[]) => { + if (!shouldIgnoreTarget(e.target as HTMLElement)) { + original(e, ...extraArgs); + } + }; + } }); - return instance; }; + +const isTouch = () => 'ontouchstart' in window || navigator.maxTouchPoints > 0; + +export const HTML5Backend = (...args: unknown[]) => { + if (isTouch()) { + // @ts-expect-error - TouchBackend args + return TouchBackend(args[0], args[1], { delayTouchStart: 200 }); + } + // @ts-expect-error - HTML5Backend is not typed + return wrapBackendListeners(new ReactDndHTML5Backend(...args)); +}; diff --git a/packages/ui/src/lib/spacing.ts b/packages/ui/src/lib/spacing.ts index 59729ca7e..63e7a2633 100644 --- a/packages/ui/src/lib/spacing.ts +++ b/packages/ui/src/lib/spacing.ts @@ -40,6 +40,10 @@ export const layout = { titleBar: { height: '64px', }, + tablet: { + touchTarget: '44px', + iconBarWidth: '56px', + }, } as const; // Component dimensions