diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36d697b..3b89010 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,6 @@ import { Tooltip, Avatar, Divider, - Burger, } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' import { useAuth } from './hooks/useAuth' diff --git a/frontend/src/assets/icons/BookmarkIcon.tsx b/frontend/src/assets/icons/BookmarkIcon.tsx new file mode 100644 index 0000000..7f2c085 --- /dev/null +++ b/frontend/src/assets/icons/BookmarkIcon.tsx @@ -0,0 +1,21 @@ +interface BookmarkIconProps { + size?: number + filled?: boolean + className?: string +} + +export default function BookmarkIcon({ size = 14, filled = false, className }: BookmarkIconProps) { + return ( + + + + ) +} diff --git a/frontend/src/assets/icons/CheckIcon.tsx b/frontend/src/assets/icons/CheckIcon.tsx new file mode 100644 index 0000000..902f652 --- /dev/null +++ b/frontend/src/assets/icons/CheckIcon.tsx @@ -0,0 +1,20 @@ +interface CheckIconProps { + size?: number + className?: string +} + +export default function CheckIcon({ size = 16, className }: CheckIconProps) { + return ( + + + + ) +} diff --git a/frontend/src/assets/icons/CloseIcon.tsx b/frontend/src/assets/icons/CloseIcon.tsx new file mode 100644 index 0000000..d076132 --- /dev/null +++ b/frontend/src/assets/icons/CloseIcon.tsx @@ -0,0 +1,23 @@ +interface CloseIconProps { + size?: number + className?: string +} + +export default function CloseIcon({ size = 18, className }: CloseIconProps) { + return ( + + + + + ) +} diff --git a/frontend/src/assets/icons/CopyIcon.tsx b/frontend/src/assets/icons/CopyIcon.tsx new file mode 100644 index 0000000..149a0d4 --- /dev/null +++ b/frontend/src/assets/icons/CopyIcon.tsx @@ -0,0 +1,21 @@ +interface CopyIconProps { + size?: number + className?: string +} + +export default function CopyIcon({ size = 14, className }: CopyIconProps) { + return ( + + + + + ) +} diff --git a/frontend/src/assets/icons/ExternalLinkIcon.tsx b/frontend/src/assets/icons/ExternalLinkIcon.tsx new file mode 100644 index 0000000..3b53283 --- /dev/null +++ b/frontend/src/assets/icons/ExternalLinkIcon.tsx @@ -0,0 +1,22 @@ +interface ExternalLinkIconProps { + size?: number + className?: string +} + +export default function ExternalLinkIcon({ size = 14, className }: ExternalLinkIconProps) { + return ( + + + + + + ) +} diff --git a/frontend/src/assets/icons/index.tsx b/frontend/src/assets/icons/index.tsx new file mode 100644 index 0000000..0acdbb7 --- /dev/null +++ b/frontend/src/assets/icons/index.tsx @@ -0,0 +1,5 @@ +export { default as BookmarkIcon } from './BookmarkIcon' +export { default as ExternalLinkIcon } from './ExternalLinkIcon' +export { default as CopyIcon } from './CopyIcon' +export { default as CloseIcon } from './CloseIcon' +export { default as CheckIcon } from './CheckIcon' diff --git a/frontend/src/components/FullScreenReader.tsx b/frontend/src/components/FullScreenReader.tsx new file mode 100644 index 0000000..b982280 --- /dev/null +++ b/frontend/src/components/FullScreenReader.tsx @@ -0,0 +1,228 @@ +import { Modal, Box, UnstyledButton } from '@mantine/core' +import { useState, useEffect, useRef, useCallback } from 'react' +import { FeedItem } from '../api/items' +import ReaderToolbar from './ReaderToolbar' +import ReaderContent from './ReaderContent' +import { CloseIcon } from '../assets/icons' + +function debounce void>(func: T, wait: number): (...args: Parameters) => void { + let timeout: ReturnType | null = null + return function executedFunction(...args: Parameters) { + const later = () => { + timeout = null + func(...args) + } + if (timeout) clearTimeout(timeout) + timeout = setTimeout(later, wait) + } +} + +interface FullScreenReaderProps { + item: FeedItem + readerMode: boolean + setReaderMode: (mode: boolean) => void + onToggleBookmark: () => Promise + onClose: () => void +} + +export default function FullScreenReader({ + item, + readerMode, + setReaderMode, + onToggleBookmark, + onClose, +}: FullScreenReaderProps) { + const [showToolbar, setShowToolbar] = useState(true) + const [showEscHint, setShowEscHint] = useState(true) + const [isBookmarked, setIsBookmarked] = useState(item.isBookmarked) + + const scrollContainerRef = useRef(null) + const lastScrollY = useRef(0) + + useEffect(() => { + setIsBookmarked(item.isBookmarked) + }, [item.isBookmarked]) + + useEffect(() => { + const timer = setTimeout(() => setShowEscHint(false), 5000) + return () => clearTimeout(timer) + }, []) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleScroll = useCallback( + debounce(() => { + const container = scrollContainerRef.current + if (!container) return + + const { scrollTop } = container + + if (scrollTop > lastScrollY.current && scrollTop > 50) { + setShowToolbar(false) + } else if (scrollTop < lastScrollY.current) { + setShowToolbar(true) + } + lastScrollY.current = scrollTop + }, 100), + [] + ) + + useEffect(() => { + const container = scrollContainerRef.current + if (!container) return + + const handleScrollEvent = () => handleScroll() + container.addEventListener('scroll', handleScrollEvent) + return () => container.removeEventListener('scroll', handleScrollEvent) + }, [handleScroll]) + + useEffect(() => { + const container = scrollContainerRef.current + if (!container) return + + container.focus() + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key.toLowerCase() === 'f' || e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + const handleClick = () => { + container.focus() + } + + container.addEventListener('keydown', handleKeyDown) + container.addEventListener('click', handleClick) + + return () => { + container.removeEventListener('keydown', handleKeyDown) + container.removeEventListener('click', handleClick) + } + }, [onClose]) + + const handleToggleBookmark = async () => { + await onToggleBookmark() + setIsBookmarked(!isBookmarked) + } + + return ( + + + + + + + + + + + + + + + + {showEscHint && ( + + Press ESC or F to exit + + )} + + ) +} diff --git a/frontend/src/components/Reader.tsx b/frontend/src/components/Reader.tsx index 5517a52..3c609c9 100644 --- a/frontend/src/components/Reader.tsx +++ b/frontend/src/components/Reader.tsx @@ -1,37 +1,60 @@ -import { Group, UnstyledButton, TextInput, Switch, Box, Text, Tooltip } from '@mantine/core' +import { Box } from '@mantine/core' import { useState, useEffect } from 'react' import { FeedItem } from '../api/items' import { showNotification } from '@mantine/notifications' -import client from '../api/client' import { useToggleBookmark } from '../hooks/useFeedItems' import { useReaderEngine } from '../hooks/useReaderEngine' - -// Get API URL from client baseURL -const API_URL = (client.defaults.baseURL as string) || 'http://localhost:3000/api' +import { useShortcutEvent } from '../hooks/useKeyboardShortcuts/useShortcutEvent' +import { fullscreenShortcut } from '../hooks/useKeyboardShortcuts/shortcuts/fullscreen' +import FullScreenReader from './FullScreenReader' +import ReaderToolbar from './ReaderToolbar' +import ReaderContent from './ReaderContent' +import { CheckIcon, BookmarkIcon } from '../assets/icons' interface ReaderProps { item: FeedItem onOpen?: () => void onToggleBookmark?: (item: FeedItem) => Promise + readerMode?: boolean + onReaderModeChange?: (mode: boolean) => void + showToolbar?: boolean } -export default function Reader({ item, onOpen, onToggleBookmark }: ReaderProps) { +export default function Reader({ + item, + onOpen, + onToggleBookmark, + readerMode: controlledReaderMode, + onReaderModeChange, + showToolbar = true +}: ReaderProps) { const [isBookmarked, setIsBookmarked] = useState(item.isBookmarked) + const [isFullScreen, setIsFullScreen] = useState(false) const toggleBookmarkMutation = useToggleBookmark() - const { readerMode, setReaderMode, iframeRef, handleIframeLoad, handleIframeError } = - useReaderEngine({ url: item.link }) + const { + readerMode: internalReaderMode, + setReaderMode: setInternalReaderMode, + iframeRef, + handleIframeLoad, + handleIframeError + } = useReaderEngine({ url: item.link }) + + const readerMode = controlledReaderMode ?? internalReaderMode + const setReaderMode = onReaderModeChange ?? setInternalReaderMode - // Sync bookmark state with item prop useEffect(() => { setIsBookmarked(item.isBookmarked) }, [item.isBookmarked]) - // Mark as read when component mounts (link is opened) useEffect(() => { onOpen?.() }, [onOpen]) + useShortcutEvent(fullscreenShortcut, () => { + setIsFullScreen(prev => !prev) + }) + const handleOpenInNewTab = () => { window.open(item.link, '_blank') onOpen?.() @@ -43,18 +66,7 @@ export default function Reader({ item, onOpen, onToggleBookmark }: ReaderProps) showNotification({ message: 'Link copied to clipboard', color: 'green', - icon: ( - - - - ), + icon: , }) } catch (error) { showNotification({ message: 'Failed to copy link', color: 'red' }) @@ -72,18 +84,7 @@ export default function Reader({ item, onOpen, onToggleBookmark }: ReaderProps) showNotification({ message: isBookmarked ? 'Bookmark removed' : 'Bookmarked', color: 'green', - icon: ( - - - - ), + icon: , }) } catch (error) { showNotification({ message: 'Failed to toggle bookmark', color: 'red' }) @@ -91,231 +92,39 @@ export default function Reader({ item, onOpen, onToggleBookmark }: ReaderProps) } return ( - - {/* Toolbar */} - - {/* URL Display */} - - - {/* Reader Mode Toggle */} - - setReaderMode(e.currentTarget.checked)} - size="sm" - color="amber" - styles={{ - track: { - cursor: 'pointer', - }, - }} - /> - - Reader - - - - {/* Actions */} - - - { - e.currentTarget.style.transform = 'translateY(-1px)' - e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 193, 7, 0.25)' - }} - onMouseLeave={e => { - e.currentTarget.style.transform = 'translateY(0)' - e.currentTarget.style.boxShadow = 'none' - }} - > - - - - - - Open - - - - - { - e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)' - e.currentTarget.style.color = 'var(--mantine-color-gray-2)' - }} - onMouseLeave={e => { - e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)' - e.currentTarget.style.color = 'var(--mantine-color-gray-4)' - }} - > - - - - - Copy - - - - - { - if (!toggleBookmarkMutation.isPending) { - e.currentTarget.style.background = isBookmarked - ? 'rgba(255, 193, 7, 0.3)' - : 'rgba(255, 255, 255, 0.1)' - e.currentTarget.style.color = isBookmarked - ? 'var(--mantine-color-yellow-4)' - : 'var(--mantine-color-gray-2)' - } - }} - onMouseLeave={e => { - if (!toggleBookmarkMutation.isPending) { - e.currentTarget.style.background = isBookmarked - ? 'rgba(255, 193, 7, 0.2)' - : 'rgba(255, 255, 255, 0.05)' - e.currentTarget.style.color = isBookmarked - ? 'var(--mantine-color-yellow-5)' - : 'var(--mantine-color-gray-4)' - } - }} - > - - - - {isBookmarked ? 'Bookmarked' : 'Bookmark'} - - - - - - {/* Content Frame */} - - {readerMode ? ( -