From 0ac7530338a211a3af85a3b0999f159c212985c9 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Thu, 8 Jan 2026 01:56:54 +0100 Subject: [PATCH 1/3] Remove unused import --- frontend/src/App.tsx | 1 - 1 file changed, 1 deletion(-) 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' From 7f83fd3dd3b0bb0a9d14b493e8ceaef23c42b918 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Thu, 8 Jan 2026 01:58:18 +0100 Subject: [PATCH 2/3] Add simple shortcut functionality --- .../hooks/useKeyboardShortcuts/dispatcher.ts | 38 ++++++ .../useKeyboardShortcuts/shortcut-manager.ts | 108 ++++++++++++++++++ .../shortcuts/fullscreen.ts | 12 ++ .../src/hooks/useKeyboardShortcuts/types.ts | 30 +++++ .../useKeyboardShortcuts/useShortcutEvent.ts | 31 +++++ 5 files changed, 219 insertions(+) create mode 100644 frontend/src/hooks/useKeyboardShortcuts/dispatcher.ts create mode 100644 frontend/src/hooks/useKeyboardShortcuts/shortcut-manager.ts create mode 100644 frontend/src/hooks/useKeyboardShortcuts/shortcuts/fullscreen.ts create mode 100644 frontend/src/hooks/useKeyboardShortcuts/types.ts create mode 100644 frontend/src/hooks/useKeyboardShortcuts/useShortcutEvent.ts 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]) +} From a5ecb5e18d9c7fcc67eb0a1f3ae937ee702b74fb Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Thu, 8 Jan 2026 01:58:53 +0100 Subject: [PATCH 3/3] Add fullscreen support to Reader --- frontend/src/assets/icons/BookmarkIcon.tsx | 21 ++ frontend/src/assets/icons/CheckIcon.tsx | 20 ++ frontend/src/assets/icons/CloseIcon.tsx | 23 ++ frontend/src/assets/icons/CopyIcon.tsx | 21 ++ .../src/assets/icons/ExternalLinkIcon.tsx | 22 ++ frontend/src/assets/icons/index.tsx | 5 + frontend/src/components/FullScreenReader.tsx | 228 +++++++++++++ frontend/src/components/Reader.tsx | 323 ++++-------------- frontend/src/components/ReaderContent.tsx | 56 +++ frontend/src/components/ReaderToolbar.tsx | 165 +++++++++ 10 files changed, 627 insertions(+), 257 deletions(-) create mode 100644 frontend/src/assets/icons/BookmarkIcon.tsx create mode 100644 frontend/src/assets/icons/CheckIcon.tsx create mode 100644 frontend/src/assets/icons/CloseIcon.tsx create mode 100644 frontend/src/assets/icons/CopyIcon.tsx create mode 100644 frontend/src/assets/icons/ExternalLinkIcon.tsx create mode 100644 frontend/src/assets/icons/index.tsx create mode 100644 frontend/src/components/FullScreenReader.tsx create mode 100644 frontend/src/components/ReaderContent.tsx create mode 100644 frontend/src/components/ReaderToolbar.tsx 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 ? ( -