diff --git a/docs/003_backlog.md b/docs/003_backlog.md index b9240f5..95845ab 100644 --- a/docs/003_backlog.md +++ b/docs/003_backlog.md @@ -145,3 +145,13 @@ This document records planned features, UX enhancements, and architectural exten - Outbound webhook action in rule engine supporting Telegram, Discord, Slack, and generic JSON HTTP POST. - Template engine with interpolation of article variables (`title`, `feed_title`, `url`, `published_at`, `ai_summary`, `tags`). - Retry mechanism with exponential backoff on delivery failure. + +--- + +## 15. Infinite Scrolling Pagination for Desktop 3-Pane Middle List + +- **Goal**: Seamlessly load more articles as the user scrolls down the middle pane in Desktop 3-Split view. +- **Scope**: + - Integrate windowed offset pagination (`limit: 50`, `offset: entries.length`) with bottom scroll threshold detection on `.desktop-articles-scroll`. + - Display non-intrusive loading spinner at the bottom of the article list while fetching subsequent pages. + - Maintain active selection index and scroll position during page append. diff --git a/docs/008_desktop_view_feedback_and_specifications.md b/docs/008_desktop_view_feedback_and_specifications.md index b9dd618..077b948 100644 --- a/docs/008_desktop_view_feedback_and_specifications.md +++ b/docs/008_desktop_view_feedback_and_specifications.md @@ -1,12 +1,14 @@ -# 008 - Desktop 3-Pane View Feedback & Design Specifications +# 008 - Desktop & Mobile View Feedback & Design Specifications -This document comprehensively records all user feedback, UX directives, and architectural decisions made for the **Desktop 3-Pane View** in RSSPipe. +This document comprehensively records all user feedback, UX directives, and architectural decisions made for both the **Desktop 3-Pane View** and the **Mobile Waterfall View** in RSSPipe. --- +# PART A: Desktop 3-Pane View Specifications + ## 1. Design Aesthetics & Visual Identity -- **Theme Palette**: Metal-inspired, refined monochrome with sleek slate/zinc surfaces (`#09090b`, `#18181b`, `#27272a`), crisp typography (Inter/system), and subtle color accents (Gold for Starred, Emerald for Tags, Blue for Keywords/AI). +- **Theme Palette**: Metal-inspired, refined monochrome with sleek slate/zinc surfaces (`#09090b`, `#18181b`, `#27272a`), crisp typography (Inter/system), and dedicated functional color accents (Gold for Starred, Emerald for Tags, Blue for Keywords/AI). - **Surface Elevation**: Clean hairline borders (`1px solid var(--border-subtle)`), eliminating heavy shadows or dated blue-gray tints. --- @@ -114,7 +116,7 @@ This document comprehensively records all user feedback, UX directives, and arch ### Automatic Read Triggers -When the reading preference is enabled (`rsspipe_auto_read_scroll`): +When the reading preference is enabled (`auto_read`): 1. **Short Articles (Content โ‰ค 1 Page)**: Automatically marked as read after staying on the article for **3 seconds**. 2. **Content Click**: Clicking anywhere inside the reader content canvas (excluding the toolbar) immediately marks as read. @@ -122,7 +124,94 @@ When the reading preference is enabled (`rsspipe_auto_read_scroll`): --- -## 6. Real-Time State Synchronization +# PART B: Mobile Waterfall View & Reader Specifications -- **Sidebar Counts**: Toggling `is_read`, `is_starred`, or `share_uuid` immediately triggers a stats reload to keep sidebar counters (`inbox`, `starred`, `shared`) synchronized in real time. -- **Persistence**: Switching feeds or categories in the sidebar updates the middle article list without abruptly reloading or clearing the active reading canvas. +## 6. General Layout & Safe Area + +- **iOS Bottom Bar Safe Area**: + - The 5-button bottom navigation bar (`.ios-bottom-nav`) is elevated with dedicated safe area breathing room (`height: 72px`, `padding-bottom: calc(16px + env(safe-area-inset-bottom))`) above the iOS virtual home bar. +- **Top Header Bar Removal**: + - Removed top branding header, search icon, and view switcher to maximize vertical reading canvas on mobile screens. +- **Interactive Left-Edge Gesture**: + - Swiping from within 45px of the left screen edge provides interactive horizontal dragging (`translateX`) with smooth slide-out dismissal animation instead of an abrupt jump. +- **Scroll Position Retention**: + - Returning from a post waterfall feed back to the previous list (Subscriptions or Starred) preserves the exact list scroll location. + +--- + +## 7. Home Tab (Discovery Waterfall) + +- **Categories Bar at Top**: + - The horizontal scrollable category pill bar sits directly at the top of the Home tab, with the `"All"` tab active by default. +- **Floating Integrated Search Bar**: + - Placed on the same top row as categories on the far right. + - Tapping the search icon smoothly expands the search bar from right to left with an integrated clear button and auto-focus. +- **Pull-to-Refresh & Discovery Reshuffle**: + - Dragging down at the top of the waterfall feed (~1/3 screen threshold) reveals a pull indicator (*"Pull down to refresh"* โž” *"Release to reload & shuffle"*). + - Releasing triggers a refresh and **randomly reshuffles post cards** for an engaging discovery experience. +- **SmartArt Post Cards (No-Image Fallback)**: + - For articles without images, SmartArt cards render clean post description text snippets instead of raw article URLs or empty containers. +- **Automatic Infinite Scroll**: + - Automatically loads more articles when scrolling to the bottom of the feed. +- **Double-Tap Home Tab**: + - Double-tapping the ๐Ÿ  Home icon on the bottom navigation bar smoothly scrolls the waterfall feed back to the top. + +--- + +## 8. Mobile Full-Screen Article Reader (`MobileArticleSheet`) + +### Top Navigation Row + +- **Single-Row Layout (`.mobile-sheet-top-nav`)**: + - `[ < Return Button ] [ Feed Favicon ] [ Feed Title ]` + - Eliminates clutter and keeps the feed identity anchored at the top. + +### Article Content Layout + +``` +1. Post Title (h1) +2. Author ยท Published Date ยท Estimated Reading Time +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +3. AI Summary Box (Purple theme, copy button) +4. Article Content Body (HTML / Full Text) +``` + +### No Horizontal Page Scrolling + +- Universal horizontal overflow protection (`overflow-x: hidden; width: 100%; max-width: 100vw;`). +- Forced word wrapping (`word-break: break-word; overflow-wrap: anywhere;`) on all article canvas text, links, and code blocks to prevent unwrapped URLs from causing horizontal viewport shifts. + +### Docked Bottom Action Toolbar (`.mobile-sheet-bottom-bar`) + +- **Bottom-Left Corner (Action Icons)**: + - โญ **Star**: Toggles between empty star and filled **Yellow/Gold** (`#d97706`). + - ๐Ÿ”— **Share**: Toggles public share link, copies URL to clipboard with confirmation banner, and highlights in **Electric Blue** (`#2563eb`). + - ๐ŸŽง **Audio / TTS**: Shows in **Emerald Green** (`#059669`) with live play/pause control when narration is available. + - โ†—๏ธ **Original Link**: Opens original web link in a new browser tab. +- **Bottom-Right Corner (Popup Split Action)**: + - Split button (`Get Full Text` / `Original`) with an upward chevron that opens a **Dropup Popup Menu**: + - ๐Ÿ”ฎ **Generate AI Summary**: **Purple** (`#7c3aed`). + - ๐ŸŽ™๏ธ **Generate Audio (TTS)**: **Emerald Green** (`#059669`). + +--- + +## 9. Harmonized Color Identity System + +| Feature | Primary Hex Code | CSS Variable / Token | State Appearance | +| :--- | :--- | :--- | :--- | +| **Star / Favorited** | `#d97706` (Amber/Gold) | `--badge-star` | Solid filled star | +| **Public Share** | `#2563eb` (Electric Blue) | `--badge-shared` | Solid blue icon + soft tint background | +| **AI Summary** | `#7c3aed` (Royal Purple) | `--badge-ai` | Purple icon, border & chip | +| **Audio / TTS** | `#059669` (Emerald Green) | `--badge-tts` | Emerald icon + soft green pill | +| **Keywords** | `#0284c7` (Sky Blue) | `--badge-keyword` | Sky blue badge chip | +| **Tags** | `#10b981` (Emerald Green) | `--badge-tag` | Emerald `#tag` chip | + +--- + +## 10. Cross-Device Settings & Real-Time Sync + +- **Server-Side Persistence**: + - All user reading preferences (`auto_read`, `view_preference`, theme) are stored in the SQLite `users.preferences` column via `GET/PUT /api/user/preferences`. +- **Multi-Session Synchronization**: + - Preferences automatically sync with `localStorage` on session start. + - Added window `focus` and tab `visibilitychange` listeners in `App.tsx` and `SettingsView.tsx`, ensuring setting changes made on mobile immediately reflect on desktop when switching windows. diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 23dca97..21ea8cf 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -134,6 +134,50 @@ func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"}) } +// User Preferences Handlers +func (h *Handler) GetUserPreferences(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims) + if !ok { + h.writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + user, err := h.db.GetUserByUsername(claims.Username) + if err != nil { + h.writeError(w, http.StatusNotFound, "user not found") + return + } + h.writeJSON(w, http.StatusOK, user.Preferences) +} + +func (h *Handler) UpdateUserPreferences(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims) + if !ok { + h.writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + user, err := h.db.GetUserByUsername(claims.Username) + if err != nil { + h.writeError(w, http.StatusNotFound, "user not found") + return + } + var newPrefs map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&newPrefs); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request payload") + return + } + if user.Preferences == nil { + user.Preferences = make(map[string]interface{}) + } + for k, v := range newPrefs { + user.Preferences[k] = v + } + if err := h.db.UpdateUserPreferences(user.ID, user.Preferences); err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + h.writeJSON(w, http.StatusOK, user.Preferences) +} + // Stats Handler func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { stats, err := h.db.GetStats() diff --git a/internal/api/server.go b/internal/api/server.go index 2ede34c..9443ae9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -45,6 +45,8 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http r.Get("/auth/me", h.GetMe) r.Put("/auth/password", h.UpdatePassword) + r.Get("/user/preferences", h.GetUserPreferences) + r.Put("/user/preferences", h.UpdateUserPreferences) r.Get("/stats", h.GetStats) // Categories diff --git a/internal/db/db.go b/internal/db/db.go index ccfa59f..dc78498 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -168,6 +168,9 @@ func (d *DB) migrate() error { WHERE (icon_url IS NULL OR icon_url = '') AND site_url IS NOT NULL AND site_url != '' `) + // Migration for preferences column on users table if older DB exists + _, _ = d.Exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT '{}'") + // Migration for rules table schema if older table exists var testCol string err := d.QueryRow("SELECT trigger FROM rules LIMIT 1").Scan(&testCol) @@ -199,13 +202,20 @@ func (d *DB) CountUsers() (int, error) { func (d *DB) GetUserByUsername(username string) (*models.User, error) { var u models.User + var prefsRaw sql.NullString err := d.QueryRow( - "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?", + "SELECT id, username, password_hash, preferences, created_at, updated_at FROM users WHERE username = ?", username, - ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &prefsRaw, &u.CreatedAt, &u.UpdatedAt) if err != nil { return nil, err } + if prefsRaw.Valid && prefsRaw.String != "" { + _ = json.Unmarshal([]byte(prefsRaw.String), &u.Preferences) + } + if u.Preferences == nil { + u.Preferences = make(map[string]interface{}) + } return &u, nil } @@ -215,12 +225,13 @@ func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) { ID: uuid.New().String(), Username: username, PasswordHash: passwordHash, + Preferences: make(map[string]interface{}), CreatedAt: now, UpdatedAt: now, } _, err := d.Exec( - "INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - u.ID, u.Username, u.PasswordHash, u.CreatedAt, u.UpdatedAt, + "INSERT INTO users (id, username, password_hash, preferences, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + u.ID, u.Username, u.PasswordHash, "{}", u.CreatedAt, u.UpdatedAt, ) if err != nil { return nil, err @@ -237,6 +248,19 @@ func (d *DB) UpdateUserPassword(userID, passwordHash string) error { return err } +func (d *DB) UpdateUserPreferences(userID string, prefs map[string]interface{}) error { + raw, err := json.Marshal(prefs) + if err != nil { + return err + } + now := time.Now().UTC() + _, err = d.Exec( + "UPDATE users SET preferences = ?, updated_at = ? WHERE id = ?", + string(raw), now, userID, + ) + return err +} + // Category operations func (d *DB) ListCategories() ([]models.Category, error) { query := ` diff --git a/internal/models/models.go b/internal/models/models.go index 3db6ea1..3b1f02b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -6,11 +6,12 @@ import ( // User represents the single admin user type User struct { - ID string `json:"id"` - Username string `json:"username"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Preferences map[string]interface{} `json:"preferences,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // SearchHistoryItem tracks user search history for autocomplete diff --git a/web/src/App.tsx b/web/src/App.tsx index 97a104a..68fdbfb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -113,6 +113,15 @@ export const App: React.FC = () => { try { const user = await api.getMe(); setCurrentUser(user); + if (user.preferences) { + if (user.preferences.auto_read !== undefined) { + localStorage.setItem('rsspipe_auto_read_scroll', user.preferences.auto_read ? 'true' : 'false'); + } + if (user.preferences.view_preference) { + setViewPreference(user.preferences.view_preference); + localStorage.setItem('rsspipe_view_preference', user.preferences.view_preference); + } + } } catch { localStorage.removeItem('rsspipe_jwt'); } finally { @@ -125,7 +134,37 @@ export const App: React.FC = () => { setCurrentUser(null); }; window.addEventListener('auth:unauthorized', handleUnauthorized); - return () => window.removeEventListener('auth:unauthorized', handleUnauthorized); + + const syncPreferences = async () => { + const token = localStorage.getItem('rsspipe_jwt'); + if (!token) return; + try { + const prefs = await api.getUserPreferences(); + if (prefs) { + if (prefs.auto_read !== undefined) { + localStorage.setItem('rsspipe_auto_read_scroll', prefs.auto_read ? 'true' : 'false'); + } + if (prefs.view_preference) { + setViewPreference(prefs.view_preference); + localStorage.setItem('rsspipe_view_preference', prefs.view_preference); + } + } + } catch { + // ignore + } + }; + + window.addEventListener('focus', syncPreferences); + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') syncPreferences(); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('auth:unauthorized', handleUnauthorized); + window.removeEventListener('focus', syncPreferences); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; }, []); // Load sidebar data (Categories, Feeds, Stats) @@ -145,6 +184,9 @@ export const App: React.FC = () => { } }, [currentUser]); + const [totalEntries, setTotalEntries] = useState(0); + const [isLoadingMore, setIsLoadingMore] = useState(false); + // Load entries matching filter const loadEntries = useCallback(async () => { if (!currentUser) return; @@ -154,9 +196,11 @@ export const App: React.FC = () => { feed_id: filter.feedId, category_id: filter.categoryId, search: filter.search || undefined, - limit: 200, + limit: 50, + offset: 0, }); setEntries(res.entries || []); + setTotalEntries(res.total || 0); if (res.entries && res.entries.length > 0) { setSelectedEntryId((prev) => { @@ -173,6 +217,30 @@ export const App: React.FC = () => { } }, [currentUser, filter]); + // Load more entries for infinite scrolling + const handleLoadMore = async () => { + if (isLoadingMore || entries.length >= totalEntries || !currentUser) return; + setIsLoadingMore(true); + try { + const res = await api.listEntries({ + view: filter.view, + feed_id: filter.feedId, + category_id: filter.categoryId, + search: filter.search || undefined, + limit: 50, + offset: entries.length, + }); + if (res.entries && res.entries.length > 0) { + setEntries((prev) => [...prev, ...res.entries]); + } + setTotalEntries(res.total || 0); + } catch (err) { + console.error('Failed to load more entries:', err); + } finally { + setIsLoadingMore(false); + } + }; + useEffect(() => { if (currentUser) { loadSidebarData(); @@ -464,6 +532,9 @@ export const App: React.FC = () => { viewPreference={viewPreference} onChangeViewPreference={handleChangeViewPreference} onSwitchToDesktop={() => setViewMode('split')} + hasMore={entries.length < totalEntries} + isLoadingMore={isLoadingMore} + onLoadMore={handleLoadMore} /> ) : ( void; onOpenAddFeed: () => void; + savedScrollTop?: number; + onScrollPosChange?: (pos: number) => void; } export const FeedsTabView: React.FC = ({ @@ -14,7 +17,25 @@ export const FeedsTabView: React.FC = ({ feeds, onSelectFeed, onOpenAddFeed, + savedScrollTop = 0, + onScrollPosChange, }) => { + const containerRef = useRef(null); + + // Restore scroll position once on initial mount + useEffect(() => { + if (containerRef.current && savedScrollTop > 0) { + containerRef.current.scrollTop = savedScrollTop; + } + }, []); + + const handleFeedClick = (feed: Feed) => { + if (containerRef.current && onScrollPosChange) { + onScrollPosChange(containerRef.current.scrollTop); + } + onSelectFeed(feed); + }; + const feedsByCategory = categories.map((cat) => ({ category: cat, feeds: feeds.filter((f) => f.category_id === cat.id), @@ -23,7 +44,16 @@ export const FeedsTabView: React.FC = ({ const uncategorizedFeeds = feeds.filter((f) => !f.category_id); return ( -
+

Subscriptions

-
- {ttsVariant?.stream_url && ( - +
+ {feed ? ( + + ) : ( + ๐Ÿ“ฐ )} - - - - - - - - + {entry.feed_title} +
+ {/* Share Copied Banner Notice */} + {shareCopiedNotice && ( +
+ + Public share link copied to clipboard! +
+ )} + {/* Scrollable Article Body */}
-
-

+
+ {/* Post Title */} +

{entry.title}

-
- {entry.feed_title} - โ€ข - {formatRelativeDate(entry.published_at)} - โ€ข + {/* Author, Published Date & Reading Time */} +
+ by {entry.author || 'Unknown'} + ยท + {formatDateTime(entry.published_at)} + ยท {estimateReadingTime(entry.content || entry.raw_content)}
- {/* AI Summary Box */} + {/* Transient Error Notice Banner */} + {aiError && ( +
+
+ + {aiError} +
+ +
+ )} + + {/* AI Summary Box (Purple Accent) */} {hasAISummary && ( -
+
- - AI Summary +
+ + AI Summary +
+
= ({
)} - {/* Rendered HTML/Markdown Content */} + {/* Rendered Article Content */}
+ + {/* 2. Docked Bottom Action Toolbar */} +
+ {/* Left Bottom Corner: Star, Share, Audio, Original Link */} +
+ {/* Star Button (Yellow fill when active) */} + + + {/* Share Button (Blue icon + fill when shared) */} + + + {/* Audio / TTS Button (Green/Emerald when active) */} + {ttsVariant?.stream_url && ( + + )} + + {/* Original Link Button */} + + + +
+ + {/* Right Bottom Corner: Popup Split Action Button */} +
+
+ + + + + {/* Popup Upwards Menu (Dropup) */} + {showActionDropdown && ( +
+ + + +
+ )} +
+
+
); }; diff --git a/web/src/components/mobile/SmartArtCard.tsx b/web/src/components/mobile/SmartArtCard.tsx index 8b4f56d..c7e6fa4 100644 --- a/web/src/components/mobile/SmartArtCard.tsx +++ b/web/src/components/mobile/SmartArtCard.tsx @@ -9,7 +9,9 @@ interface SmartArtCardProps { export const SmartArtCard: React.FC = ({ title, seed, excerpt }) => { const theme = getSmartArtTheme(seed); - const textToShow = excerpt ? stripHtml(excerpt, 90) : title; + // Clean description text without HTML tags or raw URLs + const cleanExcerpt = excerpt ? stripHtml(excerpt, 130) : ''; + const textToShow = cleanExcerpt.length > 15 ? cleanExcerpt : title; return (
void; onOpenAllStarred: () => void; + savedScrollTop?: number; + onScrollPosChange?: (pos: number) => void; } export const StarredTabView: React.FC = ({ @@ -14,7 +16,32 @@ export const StarredTabView: React.FC = ({ feeds, onSelectFeedFilter, onOpenAllStarred, + savedScrollTop = 0, + onScrollPosChange, }) => { + const containerRef = useRef(null); + + // Restore scroll position on mount + useEffect(() => { + if (containerRef.current && savedScrollTop > 0) { + containerRef.current.scrollTop = savedScrollTop; + } + }, []); + + const handleAllStarredClick = () => { + if (containerRef.current && onScrollPosChange) { + onScrollPosChange(containerRef.current.scrollTop); + } + onOpenAllStarred(); + }; + + const handleFeedClick = (feedId: string) => { + if (containerRef.current && onScrollPosChange) { + onScrollPosChange(containerRef.current.scrollTop); + } + onSelectFeedFilter(feedId); + }; + const starredEntries = entries.filter((e) => e.is_starred); // Group starred count by feed @@ -26,7 +53,16 @@ export const StarredTabView: React.FC = ({ const feedsWithStarred = feeds.filter((f) => starredCountsByFeed[f.id] > 0); return ( -
+

Starred Items

@@ -38,7 +74,7 @@ export const StarredTabView: React.FC = ({
@@ -57,7 +93,7 @@ export const StarredTabView: React.FC = ({ key={feed.id} className="sidebar-item" style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }} - onClick={() => onSelectFeedFilter(feed.id)} + onClick={() => handleFeedClick(feed.id)} >
{feed.title} diff --git a/web/src/components/mobile/WaterfallView.tsx b/web/src/components/mobile/WaterfallView.tsx index b691837..cc2d3bc 100644 --- a/web/src/components/mobile/WaterfallView.tsx +++ b/web/src/components/mobile/WaterfallView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewPreference } from '../../types'; import { extractFirstImage, formatRelativeDate } from '../../utils'; import { XHSBottomNav, MobileTab } from './XHSBottomNav'; @@ -9,12 +9,13 @@ import { FeedsTabView } from './FeedsTabView'; import { StarredTabView } from './StarredTabView'; import { SettingsView } from '../settings/SettingsView'; import { - Search, Headphones, Sparkles, ArrowLeft, + RefreshCw, + Loader2, + Search, X, - Columns3, } from 'lucide-react'; interface WaterfallViewProps { @@ -39,6 +40,9 @@ interface WaterfallViewProps { viewPreference: ViewPreference; onChangeViewPreference: (pref: ViewPreference) => void; onSwitchToDesktop?: () => void; + hasMore?: boolean; + isLoadingMore?: boolean; + onLoadMore?: () => void; } export const WaterfallView: React.FC = ({ @@ -51,6 +55,7 @@ export const WaterfallView: React.FC = ({ currentUser, onToggleRead, onToggleStar, + onUpdateEntry, onRefreshData, onOpenAddFeed, onPlayAudio, @@ -61,29 +66,140 @@ export const WaterfallView: React.FC = ({ playingEntry, viewPreference, onChangeViewPreference, - onSwitchToDesktop, + hasMore = false, + isLoadingMore = false, + onLoadMore, }) => { const [mobileTab, setMobileTab] = useState('home'); - const [selectedEntry, setSelectedEntry] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [showSearchInput, setShowSearchInput] = useState(false); + const [parentTab, setParentTab] = useState('home'); + const [selectedEntryId, setSelectedEntryId] = useState(null); + const selectedEntry = entries.find((e) => e.id === selectedEntryId) || null; + const [shuffledSeed, setShuffledSeed] = useState(0); + + // Tab Scroll Position Retention + const [feedsScrollPos, setFeedsScrollPos] = useState(0); + const [starredScrollPos, setStarredScrollPos] = useState(0); + + // Search Bar Expand State + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(filter.search || ''); + + // Pull-to-Refresh & Edge Gesture State + const [pullDistance, setPullDistance] = useState(0); + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const [edgeDeltaX, setEdgeDeltaX] = useState(0); + const [isFeedDismissing, setIsFeedDismissing] = useState(false); + + const touchStartY = useRef(null); + const edgeSwipeRef = useRef<{ startX: number; startY: number } | null>(null); + const scrollAreaRef = useRef(null); + + const pullThreshold = Math.min(window.innerHeight / 3, 110); + const currentFeedFilter = feeds.find((f) => f.id === filter.feedId); const handleCardClick = (entry: FeedEntry) => { - setSelectedEntry(entry); + setSelectedEntryId(entry.id); if (!entry.is_read) { onToggleRead(entry); } }; - const handleSearchSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onFilterChange({ search: searchQuery.trim() }); + // Return to the originating tab when exiting a feed's waterfall view + const handleReturnFromFeed = () => { + onFilterChange({ feedId: undefined }); + setMobileTab(parentTab); }; - const clearSearch = () => { - setSearchQuery(''); - setShowSearchInput(false); - onFilterChange({ search: '' }); + // Double Tap Home to Scroll to Top + const handleDoubleTapHome = () => { + scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // Touch Event Handlers (Pull-to-refresh + Left Edge Swipe to Return) + const handleTouchStart = (e: React.TouchEvent | React.MouseEvent) => { + if ('touches' in e && e.touches.length === 1 && currentFeedFilter) { + const touch = e.touches[0]; + if (touch.clientX < 45) { + edgeSwipeRef.current = { startX: touch.clientX, startY: touch.clientY }; + setEdgeDeltaX(0); + } else { + edgeSwipeRef.current = null; + } + } + + if (scrollAreaRef.current && scrollAreaRef.current.scrollTop <= 0 && !isPullRefreshing) { + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + touchStartY.current = clientY; + } else { + touchStartY.current = null; + } + }; + + const handleTouchMove = (e: React.TouchEvent | React.MouseEvent) => { + // 1. Check Left-Edge Swipe to Return Gesture with live translation + if (edgeSwipeRef.current && 'touches' in e && e.touches.length === 1 && currentFeedFilter) { + const touch = e.touches[0]; + const deltaX = touch.clientX - edgeSwipeRef.current.startX; + const deltaY = Math.abs(touch.clientY - edgeSwipeRef.current.startY); + if (deltaX > 0 && deltaX > deltaY * 1.2) { + setEdgeDeltaX(deltaX); + return; + } + } + + // 2. Pull-to-refresh + if (touchStartY.current === null || isPullRefreshing) return; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + const deltaY = clientY - touchStartY.current; + + if (deltaY > 0 && scrollAreaRef.current && scrollAreaRef.current.scrollTop <= 0) { + const damped = Math.min(deltaY * 0.45, pullThreshold + 30); + setPullDistance(damped); + } else { + setPullDistance(0); + } + }; + + const handleTouchEnd = () => { + // Check if edge swipe gesture met threshold for return + if (edgeSwipeRef.current && edgeDeltaX > 80 && currentFeedFilter) { + setIsFeedDismissing(true); + setTimeout(() => { + handleReturnFromFeed(); + setIsFeedDismissing(false); + setEdgeDeltaX(0); + }, 200); + } else { + setEdgeDeltaX(0); + } + edgeSwipeRef.current = null; + + if (touchStartY.current === null || isPullRefreshing) return; + touchStartY.current = null; + + if (pullDistance >= pullThreshold) { + setIsPullRefreshing(true); + setPullDistance(55); + setShuffledSeed(Date.now()); + onRefreshData(); + + setTimeout(() => { + setIsPullRefreshing(false); + setPullDistance(0); + }, 700); + } else { + setPullDistance(0); + } + }; + + // Infinite Scroll Trigger + const handleScroll = (e: React.UIEvent) => { + const el = e.currentTarget; + if (el.scrollHeight - el.scrollTop - el.clientHeight < 350) { + if (hasMore && !isLoadingMore && onLoadMore) { + onLoadMore(); + } + } }; // Filter entries based on active tab and search @@ -96,6 +212,16 @@ export const WaterfallView: React.FC = ({ ); } + // Shuffle display order when pull-to-refresh is triggered in Home tab + if (mobileTab === 'home' && shuffledSeed > 0) { + const shuffled = [...displayedEntries]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = (shuffledSeed + i * 37) % (i + 1); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + displayedEntries = shuffled; + } + // Split into 2 columns for Masonry layout const col1: FeedEntry[] = []; const col2: FeedEntry[] = []; @@ -108,89 +234,99 @@ export const WaterfallView: React.FC = ({ (e) => e.variants?.['tts'] || e.variants?.['podcast_tts'] ).length; - const currentFeedFilter = feeds.find((f) => f.id === filter.feedId); - return (
- {/* Mobile Top Header */} -
- {currentFeedFilter ? ( - - ) : ( -
RSSPipe
- )} - -
- {showSearchInput ? ( -
- setSearchQuery(e.target.value)} - autoFocus - /> + {/* Top Category Pill Tabs & Floating Search Row (Home View) */} + {mobileTab === 'home' && ( +
+ {isSearchOpen ? ( +
+
+ + { + setSearchQuery(e.target.value); + onFilterChange({ search: e.target.value }); + }} + autoFocus + /> + {searchQuery && ( + + )} +
- +
) : ( <> - + {/* Category Scrollable Pill Bar */} +
+ {currentFeedFilter ? ( + + ) : ( + <> + {/* Default "All" Tab */} + - {onSwitchToDesktop && ( - - )} + {/* Category Folders */} + {categories.map((cat) => ( + + ))} + + )} +
+ + {/* Floating Search Button on Far Right */} + )}
-
- - {/* Top Category Pill Tabs (In Home View) */} - {mobileTab === 'home' && !filter.feedId && ( -
- - {categories.map((cat) => ( - - ))} -
)} {/* Main Tab Content */} @@ -199,23 +335,30 @@ export const WaterfallView: React.FC = ({ categories={categories} feeds={feeds} onSelectFeed={(feed) => { + setParentTab('feeds'); onFilterChange({ feedId: feed.id, view: 'all' }); setMobileTab('home'); }} onOpenAddFeed={onOpenAddFeed} + savedScrollTop={feedsScrollPos} + onScrollPosChange={setFeedsScrollPos} /> ) : mobileTab === 'starred' ? ( e.is_starred)} feeds={feeds} onSelectFeedFilter={(feedId) => { + setParentTab('starred'); onFilterChange({ feedId, view: 'starred' }); setMobileTab('home'); }} onOpenAllStarred={() => { + setParentTab('starred'); onFilterChange({ view: 'starred', feedId: undefined }); setMobileTab('home'); }} + savedScrollTop={starredScrollPos} + onScrollPosChange={setStarredScrollPos} /> ) : mobileTab === 'settings' ? (
@@ -232,28 +375,89 @@ export const WaterfallView: React.FC = ({ />
) : ( - /* Waterfall 2-Column Grid (Home / Listen / Filtered Feeds) */ -
+ /* Waterfall 2-Column Grid (Home / Listen / Filtered Feeds) with Pull-To-Refresh & Infinite Scroll */ +
0 + ? `translateX(${edgeDeltaX}px)` + : undefined, + transition: edgeDeltaX > 0 ? 'none' : 'transform 0.22s cubic-bezier(0.32, 0.72, 0, 1)', + }} + > + {/* Pull-to-Refresh & Reshuffle Indicator */} + {(pullDistance > 0 || isPullRefreshing) && ( +
+ {isPullRefreshing ? ( +
+ + Refreshing & shuffling posts... +
+ ) : ( +
+ + + {pullDistance >= pullThreshold + ? 'Release to reload & shuffle' + : 'Pull down to refresh'} + +
+ )} +
+ )} + {displayedEntries.length === 0 ? (
No posts found
- Pull to refresh or check other categories + Pull down to refresh or check other categories
) : ( -
- {/* Column 1 */} -
- {col1.map((entry) => renderCard(entry))} + <> +
+ {/* Column 1 */} +
+ {col1.map((entry) => renderCard(entry))} +
+ {/* Column 2 */} +
+ {col2.map((entry) => renderCard(entry))} +
- {/* Column 2 */} -
- {col2.map((entry) => renderCard(entry))} -
-
+ + {/* Infinite Scroll Loading Indicator */} + {isLoadingMore && ( +
+ + Loading more posts... +
+ )} + )}
)} @@ -269,7 +473,7 @@ export const WaterfallView: React.FC = ({ /> )} - {/* iOS 5-Tab Bottom Navigation Bar */} + {/* iOS 5-Tab Bottom Navigation Bar (Elevated for Home Virtual Bar) */} { @@ -282,15 +486,18 @@ export const WaterfallView: React.FC = ({ }} stats={stats} playableCount={playableCount} + onDoubleTapHome={handleDoubleTapHome} /> {/* Slide-In Full-Screen Article Reader Sheet with Left-Edge Swipe Dismiss */} {selectedEntry && ( setSelectedEntry(null)} + feeds={feeds} + onClose={() => setSelectedEntryId(null)} onToggleStar={onToggleStar} onToggleRead={onToggleRead} + onUpdateEntry={onUpdateEntry} onPlayAudio={onPlayAudio} currentAudioUrl={currentAudioUrl} isPlayingAudio={isPlayingAudio} diff --git a/web/src/components/mobile/XHSBottomNav.tsx b/web/src/components/mobile/XHSBottomNav.tsx index 5b2ab94..df62fb7 100644 --- a/web/src/components/mobile/XHSBottomNav.tsx +++ b/web/src/components/mobile/XHSBottomNav.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Home, Rss, Headphones, Star, Settings } from 'lucide-react'; import { Stats } from '../../types'; @@ -9,6 +9,7 @@ interface XHSBottomNavProps { onTabChange: (tab: MobileTab) => void; stats: Stats | null; playableCount?: number; + onDoubleTapHome?: () => void; } export const XHSBottomNav: React.FC = ({ @@ -16,12 +17,24 @@ export const XHSBottomNav: React.FC = ({ onTabChange, stats, playableCount = 0, + onDoubleTapHome, }) => { + const lastHomeTapRef = useRef(0); + + const handleHomeClick = () => { + const now = Date.now(); + if (activeTab === 'home' && now - lastHomeTapRef.current < 350) { + onDoubleTapHome?.(); + } + lastHomeTapRef.current = now; + onTabChange('home'); + }; + return (