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 ? (
-
- ) : (
-
+
+ {isFullScreen && (
+ setIsFullScreen(false)}
+ />
+ )}
+ >
)
}
diff --git a/frontend/src/components/ReaderContent.tsx b/frontend/src/components/ReaderContent.tsx
new file mode 100644
index 0000000..a141395
--- /dev/null
+++ b/frontend/src/components/ReaderContent.tsx
@@ -0,0 +1,56 @@
+import { Box } from '@mantine/core'
+import { FeedItem } from '../api/items'
+import client from '../api/client'
+import { CSSProperties } from 'react'
+
+const API_URL = (client.defaults.baseURL as string) || 'http://localhost:3000/api'
+
+interface ReaderContentProps {
+ item: FeedItem
+ readerMode: boolean
+ iframeRef?: React.RefObject
+ onIframeLoad?: () => void
+ onIframeError?: () => void
+ additionalStyles?: CSSProperties
+}
+
+export default function ReaderContent({
+ item,
+ readerMode,
+ iframeRef,
+ onIframeLoad,
+ onIframeError,
+ additionalStyles,
+}: ReaderContentProps) {
+ return (
+
+ {readerMode ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ReaderToolbar.tsx b/frontend/src/components/ReaderToolbar.tsx
new file mode 100644
index 0000000..7886cba
--- /dev/null
+++ b/frontend/src/components/ReaderToolbar.tsx
@@ -0,0 +1,165 @@
+import { Group, UnstyledButton, TextInput, Switch, Text, Tooltip } from '@mantine/core'
+import { CSSProperties } from 'react'
+import { FeedItem } from '../api/items'
+import { BookmarkIcon, ExternalLinkIcon, CopyIcon } from '../assets/icons'
+
+interface ReaderToolbarProps {
+ item: FeedItem
+ isBookmarked: boolean
+ readerMode: boolean
+ onReaderModeChange: (mode: boolean) => void
+ onToggleBookmark: () => Promise
+ onOpenInNewTab?: () => void
+ onCopyLink?: () => void
+ isBookmarkPending?: boolean
+ showUrl?: boolean
+ showTitle?: boolean
+ containerStyle?: CSSProperties
+}
+
+export default function ReaderToolbar({
+ item,
+ isBookmarked,
+ readerMode,
+ onReaderModeChange,
+ onToggleBookmark,
+ onOpenInNewTab,
+ onCopyLink,
+ isBookmarkPending = false,
+ showUrl = true,
+ showTitle = false,
+ containerStyle,
+}: ReaderToolbarProps) {
+ const defaultStyle: CSSProperties = {
+ padding: '12px 16px',
+ background: 'rgba(255, 255, 255, 0.03)',
+ borderRadius: '12px',
+ border: '1px solid rgba(255, 255, 255, 0.05)',
+ marginBottom: 'var(--mantine-spacing-md)',
+ }
+
+ return (
+
+ {showTitle && (
+
+ {item.title}
+
+ )}
+
+ {showUrl && (
+
+ )}
+
+
+ onReaderModeChange(e.currentTarget.checked)}
+ size="sm"
+ color="amber"
+ styles={{ track: { cursor: 'pointer' } }}
+ />
+
+ Reader
+
+
+
+
+ {onOpenInNewTab && (
+
+
+
+ Open
+
+
+ )}
+ {onCopyLink && (
+
+
+
+ Copy
+
+
+ )}
+
+
+
+ {isBookmarked ? 'Bookmarked' : 'Bookmark'}
+
+
+
+
+ )
+}
diff --git a/frontend/src/hooks/useKeyboardShortcuts/dispatcher.ts b/frontend/src/hooks/useKeyboardShortcuts/dispatcher.ts
new file mode 100644
index 0000000..e1c4649
--- /dev/null
+++ b/frontend/src/hooks/useKeyboardShortcuts/dispatcher.ts
@@ -0,0 +1,38 @@
+import { ShortcutEvent, ShortcutEventDetail } from './types'
+
+class ShortcutEventDispatcher {
+ private static readonly EVENT_PREFIX = 'shortcut:'
+
+ dispatch(event: ShortcutEvent): void {
+ const customEvent = new CustomEvent(
+ `${ShortcutEventDispatcher.EVENT_PREFIX}${event.id}`,
+ {
+ detail: {
+ shortcutId: event.id,
+ key: event.key,
+ timestamp: event.timestamp,
+ originalEvent: event.originalEvent,
+ },
+ bubbles: false,
+ cancelable: false,
+ }
+ )
+
+ window.dispatchEvent(customEvent)
+ }
+
+ addEventListener(
+ shortcutId: string,
+ handler: (event: CustomEvent) => void
+ ): () => void {
+ const eventName = `${ShortcutEventDispatcher.EVENT_PREFIX}${shortcutId}`
+
+ window.addEventListener(eventName, handler as EventListener)
+
+ return () => {
+ window.removeEventListener(eventName, handler as EventListener)
+ }
+ }
+}
+
+export const shortcutEventDispatcher = new ShortcutEventDispatcher()
diff --git a/frontend/src/hooks/useKeyboardShortcuts/shortcut-manager.ts b/frontend/src/hooks/useKeyboardShortcuts/shortcut-manager.ts
new file mode 100644
index 0000000..bb15b90
--- /dev/null
+++ b/frontend/src/hooks/useKeyboardShortcuts/shortcut-manager.ts
@@ -0,0 +1,108 @@
+import { ShortcutId, ShortcutEvent, ShortcutDefinition } from './types'
+import { shortcutEventDispatcher } from './dispatcher'
+
+class KeyboardShortcutManager {
+ private definitions = new Map()
+ private keyMap = new Map>()
+ private listening = false
+
+ register(definition: ShortcutDefinition): void {
+ const existing = this.keyMap.get(definition.key.toLowerCase())
+ if (existing && existing.size > 0) {
+ const conflictIds = Array.from(existing).join(', ')
+ console.warn(
+ `[Shortcuts] Key "${definition.key}" conflict: "${definition.id}" conflicts with [${conflictIds}]`
+ )
+ }
+
+ this.definitions.set(definition.id, definition)
+
+ const key = definition.key.toLowerCase()
+ if (!this.keyMap.has(key)) {
+ this.keyMap.set(key, new Set())
+ }
+ this.keyMap.get(key)!.add(definition.id)
+ }
+
+ unregister(id: ShortcutId): void {
+ const definition = this.definitions.get(id)
+ if (!definition) return
+
+ this.definitions.delete(id)
+
+ const key = definition.key.toLowerCase()
+ this.keyMap.get(key)?.delete(id)
+ }
+
+ private handleKeyDown = (event: KeyboardEvent): void => {
+ if (this.isTypingContext(event.target)) {
+ return
+ }
+
+ const key = event.key.toLowerCase()
+ const shortcutIds = this.keyMap.get(key)
+
+ if (!shortcutIds || shortcutIds.size === 0) return
+
+ const enabledShortcuts = Array.from(shortcutIds)
+ .map(id => this.definitions.get(id))
+ .filter((def): def is ShortcutDefinition =>
+ def !== undefined && def.enabled !== false
+ )
+
+ if (enabledShortcuts.length === 0) return
+
+ const shortcut = enabledShortcuts[enabledShortcuts.length - 1]
+
+ if (shortcut.preventDefault !== false) {
+ event.preventDefault()
+ }
+
+ const shortcutEvent: ShortcutEvent = {
+ id: shortcut.id,
+ key: shortcut.key,
+ timestamp: Date.now(),
+ originalEvent: event,
+ }
+
+ shortcutEventDispatcher.dispatch(shortcutEvent)
+ }
+
+ private isTypingContext(target: EventTarget | null): boolean {
+ if (!target || !(target instanceof HTMLElement)) {
+ return false
+ }
+
+ const tagName = target.tagName.toLowerCase()
+ const isEditable = target.isContentEditable
+
+ return (
+ tagName === 'input' ||
+ tagName === 'textarea' ||
+ tagName === 'select' ||
+ isEditable
+ )
+ }
+
+ start(): void {
+ if (this.listening) return
+ window.addEventListener('keydown', this.handleKeyDown)
+ this.listening = true
+ }
+
+ stop(): void {
+ if (!this.listening) return
+ window.removeEventListener('keydown', this.handleKeyDown)
+ this.listening = false
+ }
+
+ getShortcuts(): ShortcutDefinition[] {
+ return Array.from(this.definitions.values())
+ }
+
+ getShortcutsByCategory(category: string): ShortcutDefinition[] {
+ return this.getShortcuts().filter(s => s.category === category)
+ }
+}
+
+export const shortcutManager = new KeyboardShortcutManager()
diff --git a/frontend/src/hooks/useKeyboardShortcuts/shortcuts/fullscreen.ts b/frontend/src/hooks/useKeyboardShortcuts/shortcuts/fullscreen.ts
new file mode 100644
index 0000000..0ac4b7a
--- /dev/null
+++ b/frontend/src/hooks/useKeyboardShortcuts/shortcuts/fullscreen.ts
@@ -0,0 +1,12 @@
+import { ShortcutDefinition } from '../types'
+
+export const FULLSCREEN_SHORTCUT_ID = 'reader.fullscreen.toggle'
+
+export const fullscreenShortcut: ShortcutDefinition = {
+ id: FULLSCREEN_SHORTCUT_ID,
+ key: 'f',
+ description: 'Toggle fullscreen reader',
+ category: 'reader',
+ preventDefault: true,
+ enabled: true,
+}
diff --git a/frontend/src/hooks/useKeyboardShortcuts/types.ts b/frontend/src/hooks/useKeyboardShortcuts/types.ts
new file mode 100644
index 0000000..1b53632
--- /dev/null
+++ b/frontend/src/hooks/useKeyboardShortcuts/types.ts
@@ -0,0 +1,30 @@
+export type ShortcutId = string
+
+export interface ShortcutEvent {
+ id: ShortcutId
+ key: string
+ timestamp: number
+ originalEvent: KeyboardEvent
+}
+
+export interface ShortcutDefinition {
+ id: ShortcutId
+ key: string
+ description: string
+ category?: string
+ preventDefault?: boolean
+ enabled?: boolean
+}
+
+export interface ShortcutEventDetail {
+ shortcutId: string
+ key: string
+ timestamp: number
+ originalEvent: KeyboardEvent
+}
+
+export type ShortcutEventHandler = (event: CustomEvent) => void
+
+export interface UseShortcutEventOptions {
+ enabled?: boolean
+}
diff --git a/frontend/src/hooks/useKeyboardShortcuts/useShortcutEvent.ts b/frontend/src/hooks/useKeyboardShortcuts/useShortcutEvent.ts
new file mode 100644
index 0000000..c361526
--- /dev/null
+++ b/frontend/src/hooks/useKeyboardShortcuts/useShortcutEvent.ts
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from 'react'
+import { shortcutEventDispatcher } from './dispatcher'
+import { shortcutManager } from './shortcut-manager'
+import { ShortcutDefinition, ShortcutEventHandler, UseShortcutEventOptions } from './types'
+
+export function useShortcutEvent(
+ shortcut: ShortcutDefinition,
+ handler: ShortcutEventHandler,
+ options: UseShortcutEventOptions = {}
+) {
+ const { enabled = true } = options
+ const handlerRef = useRef(handler)
+
+ useEffect(() => {
+ handlerRef.current = handler
+ }, [handler])
+
+ useEffect(() => {
+ if (!enabled) return
+
+ shortcutManager.register(shortcut)
+ shortcutManager.start()
+
+ const unsubscribe = shortcutEventDispatcher.addEventListener(
+ shortcut.id,
+ event => handlerRef.current(event)
+ )
+
+ return unsubscribe
+ }, [shortcut, enabled])
+}