diff --git a/frontend/src/components/Runtime/RuntimeControls.tsx b/frontend/src/components/Runtime/RuntimeControls.tsx new file mode 100644 index 0000000..1d3a8ee --- /dev/null +++ b/frontend/src/components/Runtime/RuntimeControls.tsx @@ -0,0 +1,401 @@ +/** + * RuntimeControls Component + * + * Control buttons for runtime presentations (offline toggle, fullscreen). + * Uses fixed pixel values (not rem or canvas units) to ensure: + * 1. Buttons maintain usable size regardless of canvas scaling + * 2. Consistent behavior during iOS pinch-zoom (avoiding WebKit rem/zoom bugs) + * + * Note: These are UI chrome controls, not design elements, so they should + * not scale with the canvas like navigation buttons do. + */ + +import React, { CSSProperties, useEffect, useRef, useState } from 'react'; +import { + mdiCloudDownload, + mdiCloudCheck, + mdiCloudOff, + mdiFullscreen, + mdiFullscreenExit, + mdiDelete, +} from '@mdi/js'; +import { toast } from 'react-toastify'; +import { useOfflineMode } from '../../hooks/useOfflineMode'; +import { useStorageQuota } from '../../hooks/useStorageQuota'; +import type { ProjectOfflineStatus } from '../../types/offline'; +import type { PreloadPage } from '../../types/preload'; +import { logger } from '../../lib/logger'; + +interface RuntimeControlsProps { + projectId: string | null; + projectSlug: string; + projectName?: string; + pages?: PreloadPage[]; + isFullscreen: boolean; + toggleFullscreen: () => void; +} + +/** + * Button colors matching BaseButton 'info', 'success', 'danger', 'warning' colors + */ +const buttonColors = { + info: { + background: '#2563EB', // bg-blue-600 + backgroundHover: '#1D4ED8', // bg-blue-700 + border: '#2563EB', + }, + success: { + background: '#059669', // bg-emerald-600 + backgroundHover: '#047857', // bg-emerald-700 + border: '#059669', + }, + danger: { + background: '#DC2626', // bg-red-600 + backgroundHover: '#B91C1C', // bg-red-700 + border: '#DC2626', + }, + warning: { + background: '#CA8A04', // bg-yellow-600 + backgroundHover: '#A16207', // bg-yellow-700 + border: '#CA8A04', + }, +}; + +/** + * Icon component using fixed pixel sizes + */ +function ControlIcon({ + path, + size = 20, + fill = 'currentColor', +}: { + path: string; + size?: number; + fill?: string; +}) { + return ( + + + + ); +} + +/** + * Button component using fixed pixel sizes + */ +function ControlButton({ + icon, + color = 'info', + onClick, + disabled = false, + title, +}: { + icon: string; + color?: 'info' | 'success' | 'danger' | 'warning'; + onClick: () => void; + disabled?: boolean; + title?: string; +}) { + const colors = buttonColors[color]; + + const buttonStyle: CSSProperties = { + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + padding: 8, + borderRadius: 6, + backgroundColor: colors.background, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + color: 'white', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.7 : 1, + transition: 'background-color 150ms, border-color 150ms', + }; + + return ( + + ); +} + +/** + * Delete button for removing offline data (smaller, subtle styling) + */ +function DeleteButton({ onClick }: { onClick: () => void }) { + const buttonStyle: CSSProperties = { + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + padding: 4, + borderRadius: 4, + backgroundColor: 'transparent', + border: 'none', + color: '#9CA3AF', // text-gray-400 + cursor: 'pointer', + transition: 'color 150ms', + }; + + return ( + + ); +} + +/** + * Offline toggle component using fixed pixel sizes + */ +function OfflineControl({ + projectId, + projectSlug, + projectName, + pages, +}: { + projectId: string | null; + projectSlug?: string; + projectName?: string; + pages?: PreloadPage[]; +}) { + const { + isOfflineCapable, + isDownloaded, + isDownloading, + status, + progress, + startDownload, + pauseDownload, + deleteOfflineData, + estimatedSize, + formatSize, + } = useOfflineMode({ + projectId, + projectSlug, + projectName, + pages, + }); + + const { canStore, isWarning, isCritical } = useStorageQuota(); + + // Track previous status to detect completion transition + const prevStatusRef = useRef(status); + + // Show toast notification when download completes + useEffect(() => { + logger.debug('[RuntimeControls] Status changed:', { + prev: prevStatusRef.current, + current: status, + }); + if (prevStatusRef.current === 'downloading' && status === 'downloaded') { + logger.debug('[RuntimeControls] Showing toast - download complete!'); + toast.success('Presentation ready for offline mode!', { + position: 'bottom-center', + autoClose: 5000, + }); + } + prevStatusRef.current = status; + }, [status]); + + // Don't render if offline not supported + if (!isOfflineCapable) { + return null; + } + + const handleClick = () => { + if (isDownloaded) { + if (confirm('Remove offline data for this project?')) { + deleteOfflineData(); + } + } else if (isDownloading) { + pauseDownload(); + } else if (status === 'error') { + startDownload(); + } else { + if (isCritical) { + alert( + 'Storage space is critically low. Please free up some space first.', + ); + return; + } + if (isWarning && estimatedSize > 0) { + if ( + !confirm( + `Storage space is running low. Download ${formatSize(estimatedSize)} anyway?`, + ) + ) { + return; + } + } + startDownload(); + } + }; + + // Determine icon and color + let icon = mdiCloudDownload; + let color: 'info' | 'success' | 'danger' | 'warning' = 'info'; + let title = 'Download for offline'; + + if (isDownloaded) { + icon = mdiCloudCheck; + color = 'success'; + title = 'Available offline'; + } else if (isDownloading) { + icon = mdiCloudDownload; + color = 'info'; + title = `Downloading ${progress}%`; + } else if (status === 'error') { + icon = mdiCloudOff; + color = 'danger'; + title = 'Retry download'; + } else if (status === 'outdated') { + icon = mdiCloudDownload; + color = 'warning'; + title = 'Update available'; + } + + const containerStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 4, + }; + + return ( +
+ + {isDownloaded && ( + { + if (confirm('Remove offline data for this project?')) { + deleteOfflineData(); + } + }} + /> + )} +
+ ); +} + +/** + * Hook to counter pinch-zoom scaling using visualViewport API + * Returns a scale factor to apply as inverse transform + */ +function useCounterZoom() { + const [scale, setScale] = useState(1); + + useEffect(() => { + // visualViewport API is supported in all modern browsers + const vv = window.visualViewport; + if (!vv) return; + + const handleResize = () => { + // visualViewport.scale gives the pinch-zoom level + // We apply 1/scale to counter it + setScale(1 / vv.scale); + }; + + // Set initial value + handleResize(); + + vv.addEventListener('resize', handleResize); + vv.addEventListener('scroll', handleResize); + + return () => { + vv.removeEventListener('resize', handleResize); + vv.removeEventListener('scroll', handleResize); + }; + }, []); + + return scale; +} + +/** + * RuntimeControls - Main component for presentation controls + * + * Renders offline toggle and fullscreen button using fixed pixel values + * to maintain usable size regardless of canvas scaling. + * Uses visualViewport API to counter pinch-zoom scaling on mobile. + */ +export default function RuntimeControls({ + projectId, + projectSlug, + projectName, + pages, + isFullscreen, + toggleFullscreen, +}: RuntimeControlsProps) { + // Counter-scale to resist pinch-zoom + const counterScale = useCounterZoom(); + + const containerStyle: CSSProperties = { + position: 'fixed', + top: 16, + right: 16, + display: 'flex', + alignItems: 'center', + gap: 8, + zIndex: 9999, // Above everything including modals + // Counter pinch-zoom scaling + transform: `scale(${counterScale})`, + transformOrigin: 'top right', + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index e440560..f2b2ba1 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -5,7 +5,6 @@ * Used by /p/[projectSlug] and /p/[projectSlug]/stage routes. */ -import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js'; import Head from 'next/head'; import React, { ReactElement, @@ -17,9 +16,8 @@ import React, { } from 'react'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; -import BaseButton from './BaseButton'; import CardBox from './CardBox'; -import { OfflineToggle } from './Offline/OfflineToggle'; +import RuntimeControls from './Runtime/RuntimeControls'; import RuntimeElement from './RuntimeElement'; import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay'; import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay'; @@ -693,24 +691,6 @@ export default function RuntimePresentation({ {/* End page elements wrapper */} - {/* Controls: Offline toggle and Fullscreen button */} -
- - -
- {/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */} {/* Opacity is 0 during 'preparing' phase to show old page while video loads */} {/* NO fade-out: video itself IS the transition (last frame = new page) */} @@ -785,6 +765,17 @@ export default function RuntimePresentation({ {/* End inner canvas container */} + {/* Controls: Offline toggle and Fullscreen button */} + {/* Positioned outside canvas to avoid scaling with canvas transform */} + + {/* Toast notifications for offline download status */}