refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.
This commit is contained in:
+272
-323
@@ -1,20 +1,20 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from './services/api';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode } from './types';
|
||||
import { LoginView } from './components/LoginView';
|
||||
import { Header } from './components/Header';
|
||||
import { ThreePaneView } from './components/ThreePaneView';
|
||||
import { WaterfallView } from './components/WaterfallView';
|
||||
import { SettingsView } from './components/SettingsView';
|
||||
import { AddFeedModal } from './components/AddFeedModal';
|
||||
import { OPMLModal } from './components/OPMLModal';
|
||||
import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode, ViewPreference } from './types';
|
||||
import { LoginView } from './components/common/LoginView';
|
||||
import { Header } from './components/common/Header';
|
||||
import { ThreePaneView } from './components/desktop/ThreePaneView';
|
||||
import { WaterfallView } from './components/mobile/WaterfallView';
|
||||
import { SettingsView } from './components/settings/SettingsView';
|
||||
import { AddFeedModal } from './components/common/AddFeedModal';
|
||||
import { OPMLModal } from './components/common/OPMLModal';
|
||||
import { KeyboardShortcutsModal } from './components/common/KeyboardShortcutsModal';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
// App State with localStorage Persistence
|
||||
// App Filter State with Persistence
|
||||
const [filter, setFilter] = useState<FilterState>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_filter');
|
||||
if (saved) {
|
||||
@@ -27,49 +27,82 @@ export const App: React.FC = () => {
|
||||
return { view: 'inbox', search: '' };
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_view_mode') as ViewMode | null;
|
||||
if (saved && (saved === 'split' || saved === 'waterfall' || saved === 'settings')) {
|
||||
return saved;
|
||||
}
|
||||
return window.innerWidth < 768 ? 'waterfall' : 'split';
|
||||
// View Preference: 'auto' | 'mobile' | 'desktop'
|
||||
const [viewPreference, setViewPreference] = useState<ViewPreference>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null;
|
||||
return saved && (saved === 'auto' || saved === 'mobile' || saved === 'desktop') ? saved : 'auto';
|
||||
});
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true';
|
||||
// Active View Mode: 'split' | 'waterfall' | 'settings'
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const savedPref = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null;
|
||||
if (savedPref === 'mobile') return 'waterfall';
|
||||
if (savedPref === 'desktop') return 'split';
|
||||
const isNarrow = typeof window !== 'undefined' ? window.innerWidth < 768 : false;
|
||||
return isNarrow ? 'waterfall' : 'split';
|
||||
});
|
||||
|
||||
const [lastContentMode, setLastContentMode] = useState<'split' | 'waterfall'>('split');
|
||||
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(() => {
|
||||
return localStorage.getItem('rsspipe_selected_entry_id');
|
||||
});
|
||||
|
||||
const [activeArticle, setActiveArticle] = useState<FeedEntry | null>(null);
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showAddFeedModal, setShowAddFeedModal] = useState(false);
|
||||
const [showOPMLModal, setShowOPMLModal] = useState(false);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
|
||||
// Persist state updates to localStorage
|
||||
// Global Audio Engine State
|
||||
const [currentAudioUrl, setCurrentAudioUrl] = useState<string | null>(null);
|
||||
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
||||
const [playingEntry, setPlayingEntry] = useState<FeedEntry | null>(null);
|
||||
|
||||
// Persist filter
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_filter', JSON.stringify(filter));
|
||||
}, [filter]);
|
||||
|
||||
// Persist view preference & synchronize active view layout
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_view_mode', viewMode);
|
||||
localStorage.setItem('rsspipe_view_preference', viewPreference);
|
||||
|
||||
const handleAutoResize = () => {
|
||||
if (viewMode === 'settings') return;
|
||||
|
||||
if (viewPreference === 'mobile') {
|
||||
setViewMode('waterfall');
|
||||
} else if (viewPreference === 'desktop') {
|
||||
setViewMode('split');
|
||||
} else {
|
||||
const isNarrow = window.innerWidth < 768;
|
||||
setViewMode(isNarrow ? 'waterfall' : 'split');
|
||||
}
|
||||
};
|
||||
|
||||
handleAutoResize();
|
||||
window.addEventListener('resize', handleAutoResize);
|
||||
return () => window.removeEventListener('resize', handleAutoResize);
|
||||
}, [viewPreference, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'split' || viewMode === 'waterfall') {
|
||||
setLastContentMode(viewMode);
|
||||
}
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_sidebar_collapsed', sidebarCollapsed ? 'true' : 'false');
|
||||
}, [sidebarCollapsed]);
|
||||
if (selectedEntryId) {
|
||||
localStorage.setItem('rsspipe_selected_entry_id', selectedEntryId);
|
||||
}
|
||||
}, [selectedEntryId]);
|
||||
|
||||
// Check auth session
|
||||
// Check auth session on startup
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
@@ -95,7 +128,7 @@ export const App: React.FC = () => {
|
||||
return () => window.removeEventListener('auth:unauthorized', handleUnauthorized);
|
||||
}, []);
|
||||
|
||||
// Load sidebar data (Feeds, Categories, Stats)
|
||||
// Load sidebar data (Categories, Feeds, Stats)
|
||||
const loadSidebarData = useCallback(async () => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
@@ -112,24 +145,34 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// Load entries matching current filter
|
||||
// Load entries matching filter
|
||||
const loadEntries = useCallback(async () => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
const res = await api.listEntries({
|
||||
view: filter.view,
|
||||
category_id: filter.categoryId,
|
||||
feed_id: filter.feedId,
|
||||
search: filter.search,
|
||||
limit: 100,
|
||||
category_id: filter.categoryId,
|
||||
search: filter.search || undefined,
|
||||
limit: 200,
|
||||
});
|
||||
setEntries(res.entries);
|
||||
setEntries(res.entries || []);
|
||||
|
||||
if (res.entries && res.entries.length > 0) {
|
||||
setSelectedEntryId((prev) => {
|
||||
if (prev && res.entries.some((e) => e.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return res.entries[0].id;
|
||||
});
|
||||
} else {
|
||||
setSelectedEntryId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load entries:', err);
|
||||
}
|
||||
}, [currentUser, filter]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
loadSidebarData();
|
||||
@@ -137,166 +180,109 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, [currentUser, loadSidebarData, loadEntries]);
|
||||
|
||||
// Sync focused index & active article when entries change without disrupting reading state
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
if (activeArticle) {
|
||||
const found = entries.find((e) => e.id === activeArticle.id);
|
||||
if (found) {
|
||||
setActiveArticle(found);
|
||||
setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id));
|
||||
return;
|
||||
}
|
||||
// Active article remains in reader canvas from prior feed, but list is unselected
|
||||
setFocusedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEntryId) {
|
||||
const found = entries.find((e) => e.id === selectedEntryId);
|
||||
if (found) {
|
||||
setActiveArticle(found);
|
||||
setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId));
|
||||
return;
|
||||
}
|
||||
// If not found in current list, load directly so reading pane stays populated
|
||||
api.getEntry(selectedEntryId).then((entry) => {
|
||||
setActiveArticle(entry);
|
||||
}).catch(() => {
|
||||
// fallback to top
|
||||
if (entries[0]) {
|
||||
setFocusedIndex(0);
|
||||
setActiveArticle(entries[0]);
|
||||
setSelectedEntryId(entries[0].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedIndex(0);
|
||||
setActiveArticle(entries[0]);
|
||||
setSelectedEntryId(entries[0].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||
}
|
||||
}, [entries]);
|
||||
|
||||
const handleSelectEntry = (index: number) => {
|
||||
setFocusedIndex(index);
|
||||
if (entries[index]) {
|
||||
setActiveArticle(entries[index]);
|
||||
setSelectedEntryId(entries[index].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
|
||||
// Global Refresh Action with guaranteed animation feedback duration
|
||||
const handleRefreshAll = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
const minDelay = new Promise((resolve) => setTimeout(resolve, 600));
|
||||
try {
|
||||
await Promise.all([
|
||||
api.refreshAllFeeds(),
|
||||
minDelay,
|
||||
]);
|
||||
await Promise.all([loadSidebarData(), loadEntries()]);
|
||||
} catch (err) {
|
||||
console.error('Refresh all failed:', err);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data only on initial mount or when user clicks refresh / changes filter
|
||||
|
||||
// Toggle Read Action with optimistic update
|
||||
const handleToggleRead = async (entry: FeedEntry, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
const newStatus = !entry.is_read;
|
||||
|
||||
// Optimistic update
|
||||
// Toggle Read Action
|
||||
const handleToggleRead = async (entry: FeedEntry) => {
|
||||
const nextRead = !entry.is_read;
|
||||
setEntries((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === entry.id ? { ...item, is_read: newStatus } : item
|
||||
)
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, is_read: nextRead } : e))
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev));
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateRead(entry.id, newStatus);
|
||||
await api.updateRead(entry.id, nextRead);
|
||||
loadSidebarData();
|
||||
} catch (err) {
|
||||
console.error('Failed to update read status:', err);
|
||||
// Revert on error
|
||||
setEntries((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === entry.id ? { ...item, is_read: !newStatus } : item
|
||||
)
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev));
|
||||
}
|
||||
console.error('Update read failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle Star Action with optimistic update
|
||||
// Toggle Star Action
|
||||
const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
const newStatus = !entry.is_starred;
|
||||
|
||||
// Optimistic update
|
||||
const nextStar = !entry.is_starred;
|
||||
setEntries((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === entry.id ? { ...item, is_starred: newStatus } : item
|
||||
)
|
||||
prev.map((e) => (e.id === entry.id ? { ...e, is_starred: nextStar } : e))
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev));
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateStar(entry.id, newStatus);
|
||||
await api.updateStar(entry.id, nextStar);
|
||||
loadSidebarData();
|
||||
} catch (err) {
|
||||
console.error('Failed to update star status:', err);
|
||||
setEntries((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === entry.id ? { ...item, is_starred: !newStatus } : item
|
||||
)
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev));
|
||||
}
|
||||
console.error('Update star failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark All Read
|
||||
// Mark All As Read Action
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await api.markAllRead(filter.feedId, filter.categoryId);
|
||||
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
|
||||
if (activeArticle) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev));
|
||||
}
|
||||
setEntries((prev) => prev.map((e) => ({ ...e, is_read: true })));
|
||||
loadSidebarData();
|
||||
} catch (err) {
|
||||
console.error('Failed to mark all read:', err);
|
||||
console.error('Mark all read failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh All Feeds
|
||||
const handleRefreshAll = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await api.refreshAllFeeds();
|
||||
setTimeout(() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
setIsRefreshing(false);
|
||||
}, 2500);
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh feeds:', err);
|
||||
setIsRefreshing(false);
|
||||
// Update Single Entry Action
|
||||
const handleUpdateEntry = (updated: FeedEntry) => {
|
||||
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
||||
loadSidebarData();
|
||||
};
|
||||
|
||||
// Audio Playback Actions
|
||||
const handlePlayAudio = (entry: FeedEntry, streamUrl: string) => {
|
||||
if (currentAudioUrl === streamUrl) {
|
||||
setIsPlayingAudio(!isPlayingAudio);
|
||||
} else {
|
||||
setCurrentAudioUrl(streamUrl);
|
||||
setPlayingEntry(entry);
|
||||
setIsPlayingAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePlayAudio = () => {
|
||||
setIsPlayingAudio(!isPlayingAudio);
|
||||
};
|
||||
|
||||
const handleCloseAudio = () => {
|
||||
setIsPlayingAudio(false);
|
||||
setCurrentAudioUrl(null);
|
||||
setPlayingEntry(null);
|
||||
};
|
||||
|
||||
// View Preference Change Handler
|
||||
const handleChangeViewPreference = (pref: ViewPreference) => {
|
||||
setViewPreference(pref);
|
||||
if (pref === 'desktop') {
|
||||
setViewMode('split');
|
||||
} else if (pref === 'mobile') {
|
||||
setViewMode('waterfall');
|
||||
} else {
|
||||
const isNarrow = window.innerWidth < 768;
|
||||
setViewMode(isNarrow ? 'waterfall' : 'split');
|
||||
}
|
||||
};
|
||||
|
||||
// Global Keyboard Shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't intercept inside input fields
|
||||
if (
|
||||
document.activeElement?.tagName === 'INPUT' ||
|
||||
document.activeElement?.tagName === 'TEXTAREA' ||
|
||||
document.activeElement?.tagName === 'SELECT'
|
||||
) {
|
||||
if (e.key === 'Escape') {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,157 +290,99 @@ export const App: React.FC = () => {
|
||||
setShowAddFeedModal(false);
|
||||
setShowOPMLModal(false);
|
||||
setShowShortcutsModal(false);
|
||||
if (viewMode === 'settings') {
|
||||
setViewMode(lastContentMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case '[': // Toggle Sidebar
|
||||
e.preventDefault();
|
||||
setSidebarCollapsed((prev) => !prev);
|
||||
break;
|
||||
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
|
||||
e.preventDefault();
|
||||
setShowShortcutsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'j':
|
||||
case 'J': // Next item
|
||||
e.preventDefault();
|
||||
if (entries.length > 0) {
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
|
||||
if (entries[next]) {
|
||||
setActiveArticle(entries[next]);
|
||||
setSelectedEntryId(entries[next].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
break;
|
||||
if (e.key === '1') {
|
||||
setViewMode('split');
|
||||
return;
|
||||
}
|
||||
if (e.key === '2') {
|
||||
setViewMode('waterfall');
|
||||
return;
|
||||
}
|
||||
if (e.key === '3') {
|
||||
setViewMode('settings');
|
||||
return;
|
||||
}
|
||||
|
||||
case 'k':
|
||||
case 'K': // Previous item
|
||||
e.preventDefault();
|
||||
if (entries.length > 0) {
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev < 0 ? 0 : Math.max(prev - 1, 0);
|
||||
if (entries[next]) {
|
||||
setActiveArticle(entries[next]);
|
||||
setSelectedEntryId(entries[next].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'v':
|
||||
case 'V': // Open original link
|
||||
{
|
||||
const targetUrl = activeArticle?.url || entries[focusedIndex]?.url;
|
||||
if (targetUrl) {
|
||||
e.preventDefault();
|
||||
window.open(targetUrl, '_blank');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 's':
|
||||
case 'S': // Toggle Star
|
||||
{
|
||||
const target = activeArticle || entries[focusedIndex];
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
handleToggleStar(target);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
case 'M': // Toggle Read / Unread
|
||||
{
|
||||
const target = activeArticle || entries[focusedIndex];
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
handleToggleRead(target);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
case 'E': // Archive / Mark read and advance
|
||||
{
|
||||
const target = activeArticle || entries[focusedIndex];
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
if (!target.is_read) {
|
||||
handleToggleRead(target);
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
setFocusedIndex((prev) => {
|
||||
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
|
||||
if (entries[next]) {
|
||||
setActiveArticle(entries[next]);
|
||||
setSelectedEntryId(entries[next].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
case 'A': // Add feed modal
|
||||
if (!e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setShowAddFeedModal(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'r':
|
||||
case 'R': // Refresh feeds
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleRefreshAll();
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case '1': // Switch to Desktop 3-Split View
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setViewMode('split');
|
||||
break;
|
||||
|
||||
case '2': // Switch to Xiaohongshu Waterfall View
|
||||
handleMarkAllRead();
|
||||
} else if (!e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setViewMode('waterfall');
|
||||
break;
|
||||
setShowAddFeedModal(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
case '3': // Switch to Settings View
|
||||
// 3-Split specific shortcuts
|
||||
if (viewMode === 'split' && entries.length > 0) {
|
||||
const currentIndex = entries.findIndex((item) => item.id === selectedEntryId);
|
||||
|
||||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setViewMode('settings');
|
||||
break;
|
||||
|
||||
case '?': // Shortcuts modal
|
||||
e.preventDefault();
|
||||
setShowShortcutsModal(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (e.shiftKey && e.key === 'A') {
|
||||
e.preventDefault();
|
||||
if (window.confirm('Mark all items in this view as read?')) {
|
||||
handleMarkAllRead();
|
||||
const nextIndex = Math.min(currentIndex + 1, entries.length - 1);
|
||||
if (entries[nextIndex]) {
|
||||
const nextEntry = entries[nextIndex];
|
||||
setSelectedEntryId(nextEntry.id);
|
||||
if (!nextEntry.is_read) {
|
||||
handleToggleRead(nextEntry);
|
||||
}
|
||||
}
|
||||
break;
|
||||
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(currentIndex - 1, 0);
|
||||
if (entries[prevIndex]) {
|
||||
const prevEntry = entries[prevIndex];
|
||||
setSelectedEntryId(prevEntry.id);
|
||||
if (!prevEntry.is_read) {
|
||||
handleToggleRead(prevEntry);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 's') {
|
||||
e.preventDefault();
|
||||
const currentEntry = entries[currentIndex];
|
||||
if (currentEntry) handleToggleStar(currentEntry);
|
||||
} else if (e.key === 'm') {
|
||||
e.preventDefault();
|
||||
const currentEntry = entries[currentIndex];
|
||||
if (currentEntry) handleToggleRead(currentEntry);
|
||||
} else if (e.key === 'o') {
|
||||
const currentEntry = entries[currentIndex];
|
||||
if (currentEntry?.url) {
|
||||
window.open(currentEntry.url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [entries, focusedIndex, activeArticle]);
|
||||
}, [viewMode, entries, selectedEntryId, isRefreshing, lastContentMode]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div style={{ color: 'var(--text-main)', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', height: '100vh', width: '100vw', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-app)' }}>
|
||||
<div style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||
Loading RSSPipe...
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,62 +390,80 @@ export const App: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginView onLoginSuccess={(user) => setCurrentUser(user)} />;
|
||||
return <LoginView onLoginSuccess={(u) => setCurrentUser(u)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* App Header */}
|
||||
<Header
|
||||
search={filter.search}
|
||||
onSearchChange={(val) => setFilter((prev) => ({ ...prev, search: val }))}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefreshAll={handleRefreshAll}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
onOpenShortcuts={() => setShowShortcutsModal(true)}
|
||||
onLogout={async () => {
|
||||
await api.logout();
|
||||
setCurrentUser(null);
|
||||
}}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
||||
/>
|
||||
{/* Top Header is rendered in 3-Split desktop view */}
|
||||
{viewMode === 'split' && (
|
||||
<Header
|
||||
search={filter.search}
|
||||
onSearchChange={(search) => setFilter((prev) => ({ ...prev, search }))}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefreshAll={handleRefreshAll}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
onOpenSettings={() => setViewMode('settings')}
|
||||
onOpenShortcuts={() => setShowShortcutsModal(true)}
|
||||
onLogout={async () => {
|
||||
await api.logout();
|
||||
setCurrentUser(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main View Mode Content */}
|
||||
{/* Main View Router */}
|
||||
{viewMode === 'split' ? (
|
||||
<ThreePaneView
|
||||
filter={filter}
|
||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
||||
onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
stats={stats}
|
||||
entries={entries}
|
||||
selectedIndex={focusedIndex}
|
||||
activeArticle={activeArticle}
|
||||
onSelectEntry={handleSelectEntry}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onUpdateEntry={(updated) => {
|
||||
setActiveArticle(updated);
|
||||
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
||||
selectedEntryId={selectedEntryId}
|
||||
onSelectEntry={(entry) => {
|
||||
setSelectedEntryId(entry.id);
|
||||
if (!entry.is_read) {
|
||||
handleToggleRead(entry);
|
||||
}
|
||||
}}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleStar={handleToggleStar}
|
||||
onToggleRead={handleToggleRead}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onUpdateEntry={handleUpdateEntry}
|
||||
stats={stats}
|
||||
onOpenSettings={() => setViewMode('settings')}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
onPlayAudio={handlePlayAudio}
|
||||
currentAudioUrl={currentAudioUrl}
|
||||
isPlayingAudio={isPlayingAudio}
|
||||
/>
|
||||
) : viewMode === 'waterfall' ? (
|
||||
<WaterfallView
|
||||
filter={filter}
|
||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
||||
onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
entries={entries}
|
||||
stats={stats}
|
||||
currentUser={currentUser}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
onUpdateEntry={(updated) => {
|
||||
setActiveArticle(updated);
|
||||
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
||||
onUpdateEntry={handleUpdateEntry}
|
||||
onRefreshData={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
onPlayAudio={handlePlayAudio}
|
||||
currentAudioUrl={currentAudioUrl}
|
||||
isPlayingAudio={isPlayingAudio}
|
||||
onTogglePlayAudio={handleTogglePlayAudio}
|
||||
onCloseAudio={handleCloseAudio}
|
||||
playingEntry={playingEntry}
|
||||
viewPreference={viewPreference}
|
||||
onChangeViewPreference={handleChangeViewPreference}
|
||||
onSwitchToDesktop={() => setViewMode('split')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsView
|
||||
@@ -530,10 +476,13 @@ export const App: React.FC = () => {
|
||||
loadEntries();
|
||||
}}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
onBack={() => setViewMode(lastContentMode)}
|
||||
viewPreference={viewPreference}
|
||||
onChangeViewPreference={handleChangeViewPreference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{/* Shared Modal Dialogs */}
|
||||
{showAddFeedModal && (
|
||||
<AddFeedModal
|
||||
categories={categories}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
interface AddFeedModalProps {
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
onFeedAdded: (feed: Feed) => void;
|
||||
}
|
||||
|
||||
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
|
||||
categories,
|
||||
onClose,
|
||||
onFeedAdded,
|
||||
}) => {
|
||||
const [feedUrl, setFeedUrl] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [newCatTitle, setNewCatTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!feedUrl.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let finalCatId: string | undefined = categoryId || undefined;
|
||||
|
||||
// If user typed a new category name
|
||||
if (newCatTitle.trim()) {
|
||||
const newCat = await api.createCategory(newCatTitle.trim());
|
||||
finalCatId = newCat.id;
|
||||
}
|
||||
|
||||
const newFeed = await api.createFeed(
|
||||
feedUrl.trim(),
|
||||
finalCatId,
|
||||
title.trim() || undefined
|
||||
);
|
||||
|
||||
onFeedAdded(newFeed);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add feed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Add New Subscription</h3>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-body">
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Feed or Website URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-input"
|
||||
value={feedUrl}
|
||||
onChange={(e) => setFeedUrl(e.target.value)}
|
||||
placeholder="https://news.ycombinator.com/rss"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Custom Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Leave blank to auto-detect from feed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Category / Folder</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">(No Category)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Or Create New Category</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={newCatTitle}
|
||||
onChange={(e) => setNewCatTitle(e.target.value)}
|
||||
placeholder="e.g. AI & Tech"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading || !feedUrl.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" /> Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={16} /> Subscribe
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,152 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FeedEntry } from '../types';
|
||||
import { estimateReadingTime } from '../utils';
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ArticleModalProps {
|
||||
entry: FeedEntry;
|
||||
onClose: () => void;
|
||||
onToggleStar: (e?: React.MouseEvent) => void;
|
||||
onToggleRead: (e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ArticleModal: React.FC<ArticleModalProps> = ({
|
||||
entry,
|
||||
onClose,
|
||||
onToggleStar,
|
||||
onToggleRead,
|
||||
}) => {
|
||||
const shareUrl = `${window.location.origin}/share/${entry.share_uuid || entry.id}.html`;
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (entry.is_read) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollHeight <= clientHeight + 40) return;
|
||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
||||
if (scrollProgress >= 0.9) {
|
||||
onToggleRead();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="article-drawer-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="article-drawer-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Header Actions */}
|
||||
<div className="article-drawer-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: entry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={onToggleStar}
|
||||
title={entry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
||||
>
|
||||
<Star
|
||||
size={17}
|
||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: entry.is_read ? 'var(--success)' : undefined,
|
||||
}}
|
||||
onClick={onToggleRead}
|
||||
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
>
|
||||
<CheckCircle2 size={17} />
|
||||
</button>
|
||||
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginLeft: '0.5rem' }}>
|
||||
{entry.feed_title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<a
|
||||
href={shareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
<span>Share</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>Original Link</span>
|
||||
</a>
|
||||
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Scroll Body */}
|
||||
<div className="article-drawer-scroll" onScroll={handleScroll}>
|
||||
<div className="reader-canvas" style={{ padding: 0 }}>
|
||||
<div className="reader-source-bar">
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||
{entry.feed_title}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={13} />{' '}
|
||||
{new Date(entry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
||||
{entry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={13} /> {entry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
{entry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: entry.content || entry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,216 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FeedEntry } from '../types';
|
||||
import {
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CompactListViewProps {
|
||||
entries: FeedEntry[];
|
||||
selectedIndex: number;
|
||||
onSelectEntry: (index: number) => void;
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const CompactListView: React.FC<CompactListViewProps> = ({
|
||||
entries,
|
||||
selectedIndex,
|
||||
onSelectEntry,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
}) => {
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
||||
const el = rowRefs.current[selectedIndex];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, entries.length]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🎉</div>
|
||||
<h3>Inbox Zero!</h3>
|
||||
<p>No articles to display in this view.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedEntry = entries[selectedIndex] || entries[0];
|
||||
|
||||
return (
|
||||
<div className="compact-layout">
|
||||
{/* Left List Pane (Gmail style) */}
|
||||
<div className="compact-list">
|
||||
{entries.map((entry, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const pubDate = new Date(entry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
// Strip HTML tags for clean 1-line snippet
|
||||
const snippet = (entry.content || entry.raw_content || '')
|
||||
.replace(/<[^>]*>?/gm, '')
|
||||
.substring(0, 120);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
ref={(el) => (rowRefs.current[index] = el)}
|
||||
className={`compact-row ${isSelected ? 'focused' : ''} ${
|
||||
entry.is_read ? 'is-read' : ''
|
||||
}`}
|
||||
onClick={() => onSelectEntry(index)}
|
||||
>
|
||||
{/* Star toggle */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: entry.is_starred ? 'var(--star)' : 'var(--text-muted)',
|
||||
}}
|
||||
onClick={(e) => onToggleStar(entry, e)}
|
||||
title={entry.is_starred ? 'Unstar' : 'Star'}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Mark read toggle */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: entry.is_read ? 'var(--success)' : 'var(--unread-badge)',
|
||||
}}
|
||||
onClick={(e) => onToggleRead(entry, e)}
|
||||
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
</button>
|
||||
|
||||
{/* Feed Title */}
|
||||
<div className="compact-row-feed" title={entry.feed_title}>
|
||||
{entry.feed_title}
|
||||
</div>
|
||||
|
||||
{/* Article Title & Snippet */}
|
||||
<div className="compact-row-main">
|
||||
<span className="compact-row-title">{entry.title}</span>
|
||||
<span className="compact-row-snippet"> - {snippet}</span>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="compact-row-date">{pubDate}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Reading Pane */}
|
||||
{selectedEntry && (
|
||||
<div className="compact-reading-pane">
|
||||
<div className="reading-pane-header">
|
||||
<div className="stream-source-meta">
|
||||
<span className="stream-source-badge">{selectedEntry.feed_title}</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{' '}
|
||||
{new Date(selectedEntry.published_at).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reading-pane-title">
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
{selectedEntry.title} ↗
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{/* Actions Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => onToggleRead(selectedEntry)}
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
<span>{selectedEntry.is_read ? 'Mark as Unread (M)' : 'Mark as Read (M)'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
style={{ color: selectedEntry.is_starred ? 'var(--star)' : undefined }}
|
||||
onClick={() => onToggleStar(selectedEntry)}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
<span>{selectedEntry.is_starred ? 'Starred (S)' : 'Star (S)'}</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
<span>Open Original (V)</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
>
|
||||
<Share2 size={15} />
|
||||
<span>Public Share</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="reading-pane-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,274 +0,0 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { ViewMode } from '../types';
|
||||
import {
|
||||
Rss,
|
||||
Search,
|
||||
Columns3,
|
||||
LayoutGrid,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
search: string;
|
||||
onSearchChange: (val: string) => void;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
isRefreshing: boolean;
|
||||
onRefreshAll: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
onLogout: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
isRefreshing,
|
||||
onRefreshAll,
|
||||
onOpenAddFeed,
|
||||
onOpenShortcuts,
|
||||
onLogout,
|
||||
sidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === '/' &&
|
||||
document.activeElement?.tagName !== 'INPUT' &&
|
||||
document.activeElement?.tagName !== 'TEXTAREA'
|
||||
) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
{/* Left: Logo & Sidebar Toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||
{!isMobile && viewMode === 'split' && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onToggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show Sidebar ( [ )' : 'Hide Sidebar ( [ )'}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="logo-area"
|
||||
onClick={() => onViewModeChange(isMobile ? 'waterfall' : 'split')}
|
||||
>
|
||||
<div style={{ color: 'var(--accent)', display: 'flex', alignItems: 'center' }}>
|
||||
<Rss size={19} />
|
||||
</div>
|
||||
<span>RSSPipe</span>
|
||||
{!isMobile && <span className="logo-badge">Gateway</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Search Bar */}
|
||||
<div className="search-bar-container">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={isMobile ? 'Search...' : "Search feeds, 'is:unread'..."}
|
||||
/>
|
||||
{!search && !isMobile && <span className="search-shortcut-hint">/</span>}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
{isMobile ? (
|
||||
// Mobile Header Right: 3 Dots Menu Button
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
title="Menu"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 40 }}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<div className="mobile-menu-dropdown">
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onOpenAddFeed();
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onRefreshAll();
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
<span>Refresh Feeds</span>
|
||||
</button>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onViewModeChange('waterfall');
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={15} />
|
||||
<span>Waterfall Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onViewModeChange('split');
|
||||
}}
|
||||
>
|
||||
<Columns3 size={15} />
|
||||
<span>3-Split View</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onViewModeChange('settings');
|
||||
}}
|
||||
>
|
||||
<Settings size={15} />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Desktop Header Right Actions
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={onOpenAddFeed}
|
||||
title="Add new subscription (A)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onRefreshAll}
|
||||
title="Refresh all feeds (R)"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="view-mode-pill">
|
||||
<button
|
||||
className={`btn-icon ${viewMode === 'split' ? 'active' : ''}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
onClick={() => onViewModeChange('split')}
|
||||
title="Desktop 3-Pane Split View (1)"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-icon ${viewMode === 'waterfall' ? 'active' : ''}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
onClick={() => onViewModeChange('waterfall')}
|
||||
title="Xiaohongshu Waterfall View (2)"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-icon ${viewMode === 'settings' ? 'active' : ''}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
onClick={() => onViewModeChange('settings')}
|
||||
title="Settings & Subscriptions (3)"
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenShortcuts}
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<HelpCircle size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onLogout}
|
||||
title="Sign Out"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react';
|
||||
import { X, Keyboard } from 'lucide-react';
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const shortcuts = [
|
||||
{ key: 'j / k', desc: 'Navigate to Next / Previous article' },
|
||||
{ key: 'o / Enter', desc: 'Open / Expand selected article' },
|
||||
{ key: 'v', desc: 'Open original link in a new browser tab' },
|
||||
{ key: 's', desc: 'Star / Unstar article' },
|
||||
{ key: 'm', desc: 'Toggle Read / Unread status' },
|
||||
{ key: 'e / Shift+I', desc: 'Archive / Mark Read and advance to next' },
|
||||
{ key: 'Shift + A', desc: 'Mark all items in current view as read' },
|
||||
{ key: '/', desc: 'Focus search bar' },
|
||||
{ key: 'r', desc: 'Refresh all feeds' },
|
||||
{ key: '1', desc: 'Switch to Reeder 4 Minimal 3-Pane Split View' },
|
||||
{ key: '2', desc: 'Switch to Xiaohongshu Waterfall / Masonry View' },
|
||||
{ key: '3', desc: 'Switch to Continuous Stream View' },
|
||||
{ key: 'a', desc: 'Add new subscription modal' },
|
||||
{ key: '?', desc: 'Open keyboard shortcuts cheatsheet' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Keyboard size={20} style={{ color: 'var(--accent)' }} />
|
||||
<h3>Google Reader & Gmail Keyboard Shortcuts</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
SHORTCUT
|
||||
</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
ACTION
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortcuts.map((sc, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '0.65rem 0.75rem' }}>
|
||||
<kbd
|
||||
style={{
|
||||
background: 'var(--bg-main)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{sc.key}
|
||||
</kbd>
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.75rem', color: 'var(--text-secondary)' }}>
|
||||
{sc.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Press <kbd style={{ padding: '2px 5px', border: '1px solid var(--border)', borderRadius: 3 }}>ESC</kbd> to close any modal.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { User } from '../types';
|
||||
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginViewProps {
|
||||
onLoginSuccess: (user: User) => void;
|
||||
}
|
||||
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('admin123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.login(username, password);
|
||||
onLoginSuccess(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
<div style={{ width: 36, height: 36, color: 'var(--accent)' }}>
|
||||
<Rss size={36} />
|
||||
</div>
|
||||
<span>RSSPipe</span>
|
||||
</div>
|
||||
<p className="login-subtitle">RSS-Driven Information Gateway</p>
|
||||
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<UserIcon size={14} /> Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Lock size={14} /> Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', marginTop: '1rem', fontSize: '0.95rem' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" /> Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In <ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Default single-user credentials: <code>admin</code> / <code>admin123456</code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, FilterState, Stats } from '../types';
|
||||
import {
|
||||
Inbox,
|
||||
Star,
|
||||
Layers,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Rss,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
stats: Stats | null;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
stats,
|
||||
}) => {
|
||||
const [collapsedCats, setCollapsedCats] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] }));
|
||||
};
|
||||
|
||||
// Group feeds by category
|
||||
const categorizedFeeds: Record<string, Feed[]> = {};
|
||||
const uncategorizedFeeds: Feed[] = [];
|
||||
|
||||
feeds.forEach((f) => {
|
||||
if (f.category_id) {
|
||||
if (!categorizedFeeds[f.category_id]) {
|
||||
categorizedFeeds[f.category_id] = [];
|
||||
}
|
||||
categorizedFeeds[f.category_id].push(f);
|
||||
} else {
|
||||
uncategorizedFeeds.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sidebar-scroll-area">
|
||||
{/* Core Navigation Items */}
|
||||
<div
|
||||
className={`sidebar-nav-item ${
|
||||
filter.view === 'inbox' && !filter.feedId && !filter.categoryId
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'inbox',
|
||||
feedId: undefined,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Inbox size={16} />
|
||||
<span>Inbox</span>
|
||||
</div>
|
||||
{stats && stats.total_unread > 0 && (
|
||||
<span className="badge unread">{stats.total_unread}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sidebar-nav-item ${
|
||||
filter.view === 'starred' && !filter.feedId && !filter.categoryId
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'starred',
|
||||
feedId: undefined,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Star size={16} style={{ color: 'var(--star)' }} />
|
||||
<span>Starred</span>
|
||||
</div>
|
||||
{stats && stats.total_starred > 0 && (
|
||||
<span className="badge">{stats.total_starred}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sidebar-nav-item ${
|
||||
filter.view === 'all' && !filter.feedId && !filter.categoryId
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'all',
|
||||
feedId: undefined,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Layers size={16} />
|
||||
<span>All Articles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<div className="sidebar-section-header">
|
||||
<span>Categories</span>
|
||||
</div>
|
||||
|
||||
{categories.map((cat) => {
|
||||
const catFeeds = categorizedFeeds[cat.id] || [];
|
||||
const isCollapsed = collapsedCats[cat.id];
|
||||
const isCatActive = filter.categoryId === cat.id && !filter.feedId;
|
||||
const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0);
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<div
|
||||
className={`sidebar-nav-item ${isCatActive ? 'active' : ''}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
categoryId: cat.id,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<button
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: 0,
|
||||
marginRight: -3,
|
||||
}}
|
||||
onClick={(e) => toggleCategoryCollapse(cat.id, e)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
{isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />}
|
||||
<span>{cat.title}</span>
|
||||
</div>
|
||||
|
||||
{catUnread > 0 && <span className="badge unread">{catUnread}</span>}
|
||||
</div>
|
||||
|
||||
{/* Category feeds */}
|
||||
{!isCollapsed &&
|
||||
catFeeds.map((feed) => {
|
||||
const isFeedActive = filter.feedId === feed.id;
|
||||
return (
|
||||
<div
|
||||
key={feed.id}
|
||||
className={`feed-item ${isFeedActive ? 'active' : ''}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
feedId: feed.id,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Rss size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span className="feed-title-text" title={feed.title}>
|
||||
{feed.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge unread">{feed.unread_count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uncategorized Feeds */}
|
||||
{uncategorizedFeeds.length > 0 && (
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<div className="sidebar-section-header">
|
||||
<span>Feeds</span>
|
||||
</div>
|
||||
|
||||
{uncategorizedFeeds.map((feed) => {
|
||||
const isFeedActive = filter.feedId === feed.id;
|
||||
return (
|
||||
<div
|
||||
key={feed.id}
|
||||
className={`sidebar-nav-item ${isFeedActive ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '0.65rem' }}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
feedId: feed.id,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Rss size={14} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span className="feed-title-text" title={feed.title}>
|
||||
{feed.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge unread">{feed.unread_count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,569 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
CheckCheck,
|
||||
FileText,
|
||||
Zap,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Loader2,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ThreePaneViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
stats: Stats | null;
|
||||
entries: FeedEntry[];
|
||||
selectedIndex: number;
|
||||
activeArticle?: FeedEntry | null;
|
||||
onSelectEntry: (index: number) => void;
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
stats,
|
||||
entries,
|
||||
selectedIndex,
|
||||
activeArticle,
|
||||
onSelectEntry,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onMarkAllRead,
|
||||
onUpdateEntry,
|
||||
sidebarCollapsed,
|
||||
}) => {
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||
const summaryCardRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
|
||||
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
||||
|
||||
// Auto-scroll list to active card
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
||||
const el = cardRefs.current[selectedIndex];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, entries.length]);
|
||||
|
||||
// Reset variant selection when article changes
|
||||
useEffect(() => {
|
||||
if (selectedEntry?.variants?.['readability_full_text']) {
|
||||
// If full text is available, you can choose to stay on current or default to original
|
||||
}
|
||||
}, [selectedEntry?.id]);
|
||||
|
||||
// When switching selected article, reset reader scroll position to top and handle 2s timer for short content
|
||||
useEffect(() => {
|
||||
if (readerPaneRef.current) {
|
||||
readerPaneRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
|
||||
const autoReadBottom = localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
||||
if (!autoReadBottom) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Small delay to allow DOM layout calculation
|
||||
const timeout = setTimeout(() => {
|
||||
if (readerPaneRef.current) {
|
||||
const { scrollHeight, clientHeight } = readerPaneRef.current;
|
||||
// Condition 2: If content is < 1 page (not scrollable, already at bottom), mark as read after 2 seconds
|
||||
if (scrollHeight <= clientHeight + 35) {
|
||||
timer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
||||
|
||||
// Reading Detection 1: Any active scroll on content >= 1 page immediately marks as read
|
||||
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
const { scrollTop } = e.currentTarget;
|
||||
if (scrollTop > 0) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
};
|
||||
|
||||
// Reading Detection 2: Clicking anywhere inside the content canvas marks as read
|
||||
const handleContentPaneClick = () => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrapeFullText = async () => {
|
||||
if (!selectedEntry || isScraping) return;
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const updated = await api.scrapeEntry(selectedEntry.id);
|
||||
if (onUpdateEntry) {
|
||||
onUpdateEntry(updated);
|
||||
}
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} catch (err: any) {
|
||||
alert(`Scraping failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getListTitle = () => {
|
||||
if (filter.search) return `Search: "${filter.search}"`;
|
||||
if (filter.feedId) {
|
||||
const f = feeds.find((item) => item.id === filter.feedId);
|
||||
return f ? f.title : 'Feed';
|
||||
}
|
||||
if (filter.categoryId) {
|
||||
const c = categories.find((item) => item.id === filter.categoryId);
|
||||
return c ? c.title : 'Category';
|
||||
}
|
||||
if (filter.view === 'starred') return 'Starred';
|
||||
if (filter.view === 'all') return 'All Articles';
|
||||
return 'Inbox';
|
||||
};
|
||||
|
||||
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
|
||||
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
|
||||
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
|
||||
|
||||
const getRenderedContent = () => {
|
||||
if (!selectedEntry) return '';
|
||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||
}
|
||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="three-pane-container">
|
||||
{/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */}
|
||||
<div className={`sidebar-pane ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
{!sidebarCollapsed && (
|
||||
<Sidebar
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
stats={stats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pane 2: Article List (Reeder 4 minimal style) */}
|
||||
<div className="article-list-pane">
|
||||
<div className="article-list-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
<span>{getListTitle()}</span>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{entries.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onMarkAllRead}
|
||||
title="Mark all as read (Shift + A)"
|
||||
style={{ width: 28, height: 28 }}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="article-list-scroll-area">
|
||||
{entries.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>✨</div>
|
||||
<h3 style={{ fontSize: '0.9rem' }}>Inbox Zero</h3>
|
||||
<p style={{ fontSize: '0.75rem' }}>No articles to read here.</p>
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const snippet = stripHtml(entry.content || entry.raw_content, 100);
|
||||
const relativeTime = formatRelativeDate(entry.published_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
className={`article-item-card ${isSelected ? 'selected' : ''} ${
|
||||
entry.is_read ? 'is-read' : ''
|
||||
}`}
|
||||
onClick={() => onSelectEntry(index)}
|
||||
>
|
||||
<div className="article-item-top">
|
||||
<span className="article-item-source">{entry.feed_title}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{relativeTime}</span>
|
||||
{!entry.is_read && <div className="unread-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
||||
|
||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
||||
|
||||
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{getEntryTags(entry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
{entry.variants?.['ai_summary'] && (
|
||||
<span
|
||||
className="variant-tag-chip"
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
color: '#34d399',
|
||||
borderColor: 'rgba(16, 185, 129, 0.25)',
|
||||
}}
|
||||
>
|
||||
✨ AI Summary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane 3: Distraction-Free Reading Canvas */}
|
||||
<div
|
||||
className="reader-pane"
|
||||
ref={readerPaneRef}
|
||||
onScroll={handleReaderScroll}
|
||||
onClick={handleContentPaneClick}
|
||||
tabIndex={0}
|
||||
>
|
||||
{selectedEntry ? (
|
||||
<>
|
||||
{/* Top Toolbar */}
|
||||
<div className="reader-toolbar" onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleStar(selectedEntry)}
|
||||
title={selectedEntry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
||||
>
|
||||
<Star
|
||||
size={16}
|
||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleRead(selectedEntry)}
|
||||
title={selectedEntry.is_read ? 'Mark Unread (M)' : 'Mark Read (M)'}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
</button>
|
||||
|
||||
{/* Sliding Ball Capsule Toggle Button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`capsule-switch-btn ${
|
||||
selectedVariantKey === 'readability_full_text' ? 'active' : ''
|
||||
} ${isScraping ? 'scraping' : ''}`}
|
||||
onClick={() => {
|
||||
if (isScraping) return;
|
||||
if (selectedVariantKey === 'readability_full_text') {
|
||||
setSelectedVariantKey('original');
|
||||
} else {
|
||||
if (hasReadability) {
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} else {
|
||||
handleScrapeFullText();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
selectedVariantKey === 'readability_full_text'
|
||||
? 'Switch to Original RSS content'
|
||||
: hasReadability
|
||||
? 'Switch to Clean Full Text'
|
||||
: 'Scrape and view Clean Full Text'
|
||||
}
|
||||
>
|
||||
<div className="capsule-switch-track">
|
||||
<span className="capsule-switch-label">
|
||||
{isScraping
|
||||
? 'Scraping...'
|
||||
: selectedVariantKey === 'readability_full_text'
|
||||
? 'Full Text'
|
||||
: 'Original'}
|
||||
</span>
|
||||
<div className="capsule-switch-thumb">
|
||||
{isScraping ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : selectedVariantKey === 'readability_full_text' ? (
|
||||
<Zap size={12} />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasAISummary && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 9px',
|
||||
fontSize: '0.72rem',
|
||||
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
||||
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
||||
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
marginLeft: '0.4rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = !showAISummary;
|
||||
setShowAISummary(next);
|
||||
if (next) {
|
||||
setTimeout(() => {
|
||||
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
||||
>
|
||||
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
||||
<span>Summary</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasTTS && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 9px',
|
||||
fontSize: '0.72rem',
|
||||
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('tts')}
|
||||
title="Audio Stream"
|
||||
>
|
||||
<Volume2 size={12} style={{ marginRight: 4 }} />
|
||||
Audio
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<a
|
||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
<span>Share</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>Original Link (V)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Content */}
|
||||
<div className="reader-content-canvas">
|
||||
<div className="article-header">
|
||||
<div className="article-header-meta">
|
||||
<span className="article-feed-badge">
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
<span className="article-meta-divider">•</span>
|
||||
<span className="article-meta-item">
|
||||
<Clock size={12} />
|
||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="article-meta-divider">•</span>
|
||||
<span className="article-meta-item">{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span className="article-meta-divider">•</span>
|
||||
<span className="article-meta-item">
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{getEntryTags(selectedEntry.variants).length > 0 && (
|
||||
<div className="article-tags-row">
|
||||
{getEntryTags(selectedEntry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audio player if TTS variant is selected */}
|
||||
{selectedVariantKey === 'tts' && (
|
||||
<div style={{ margin: '1rem 0', padding: '1rem', background: 'var(--bg-tertiary)', borderRadius: '8px' }}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>🎧 Audio Stream:</p>
|
||||
<audio
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Summary Card if present and visible */}
|
||||
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
||||
<div
|
||||
ref={summaryCardRef}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.05)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.22)',
|
||||
borderRadius: 10,
|
||||
padding: '1rem 1.15rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
||||
<Sparkles size={15} />
|
||||
<span>AI Summary & Takeaways</span>
|
||||
{selectedEntry.variants.ai_summary.metadata?.model && (
|
||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Copy Summary"
|
||||
onClick={() => {
|
||||
if (selectedEntry.variants?.ai_summary?.text_value) {
|
||||
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Hide Summary"
|
||||
onClick={() => setShowAISummary(false)}
|
||||
>
|
||||
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getRenderedContent(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📖</div>
|
||||
<h3>No Article Selected</h3>
|
||||
<p>Select an article from the list or press 'j' / 'k' to read.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { Category, FeedEntry, FilterState } from '../types';
|
||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
Inbox,
|
||||
Star as StarIcon,
|
||||
Layers,
|
||||
Rss,
|
||||
ArrowLeft,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
FileText,
|
||||
Zap,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WaterfallViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
entries: FeedEntry[];
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
}
|
||||
|
||||
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
entries,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onUpdateEntry,
|
||||
}) => {
|
||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const summaryCardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset variant selection when selected article changes
|
||||
useEffect(() => {
|
||||
setSelectedVariantKey('original');
|
||||
}, [selectedEntry?.id]);
|
||||
|
||||
const handleScrapeFullText = async () => {
|
||||
if (!selectedEntry || isScraping) return;
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const updated = await api.scrapeEntry(selectedEntry.id);
|
||||
setSelectedEntry(updated);
|
||||
onUpdateEntry?.(updated);
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} catch (err: any) {
|
||||
alert(`Scraping failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
|
||||
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
|
||||
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
|
||||
|
||||
const getRenderedContent = () => {
|
||||
if (!selectedEntry) return '';
|
||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||
}
|
||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||
};
|
||||
|
||||
// When card is clicked, open in full-screen reader view and immediately mark as read
|
||||
const handleCardClick = (entry: FeedEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
if (!entry.is_read) {
|
||||
onToggleRead(entry);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="xhs-waterfall-container">
|
||||
{/* Top Category Tabs (Xiaohongshu App Style - No Left Sidebar) */}
|
||||
<div className="xhs-tabs-bar">
|
||||
<button
|
||||
className={`xhs-tab-item ${
|
||||
filter.view === 'inbox' && !filter.categoryId ? 'active' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'inbox',
|
||||
categoryId: undefined,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Inbox size={13} />
|
||||
<span>Inbox</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`xhs-tab-item ${
|
||||
filter.view === 'starred' && !filter.categoryId ? 'active' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'starred',
|
||||
categoryId: undefined,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<StarIcon size={13} />
|
||||
<span>Starred</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`xhs-tab-item ${
|
||||
filter.view === 'all' && !filter.categoryId ? 'active' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'all',
|
||||
categoryId: undefined,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Layers size={13} />
|
||||
<span>All</span>
|
||||
</button>
|
||||
|
||||
{/* Categories as Horizontal Chips */}
|
||||
{categories.map((cat) => {
|
||||
const isActive = filter.categoryId === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`xhs-tab-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
categoryId: cat.id,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>{cat.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* True Xiaohongshu Waterfall (Masonry Grid with Staggered Heights) */}
|
||||
<div className="xhs-waterfall-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🎉</div>
|
||||
<h3>Inbox Zero</h3>
|
||||
<p>No articles to display in this feed.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xhs-masonry">
|
||||
{entries.map((entry) => {
|
||||
const coverImg = extractFirstImage(entry.content || entry.raw_content);
|
||||
const snippet = stripHtml(entry.content || entry.raw_content, 90);
|
||||
const relativeDate = formatRelativeDate(entry.published_at);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="xhs-masonry-item">
|
||||
<div
|
||||
className={`xhs-card ${entry.is_read ? 'is-read' : ''}`}
|
||||
onClick={() => handleCardClick(entry)}
|
||||
>
|
||||
{/* Card Cover (Xiaohongshu style image / fallback) */}
|
||||
<div className="xhs-cover-wrap">
|
||||
{coverImg ? (
|
||||
<img
|
||||
src={coverImg}
|
||||
alt=""
|
||||
className="xhs-cover-img"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="xhs-cover-fallback">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<Rss size={20} />
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.feed_title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="xhs-card-body">
|
||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
||||
|
||||
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4, marginBottom: 4 }}>
|
||||
{getEntryTags(entry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
{entry.variants?.['ai_summary'] && (
|
||||
<span
|
||||
className="variant-tag-chip"
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
color: '#34d399',
|
||||
borderColor: 'rgba(16, 185, 129, 0.25)',
|
||||
}}
|
||||
>
|
||||
✨ AI Summary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="xhs-card-footer">
|
||||
<div className="xhs-publisher-badge">
|
||||
<span>{entry.feed_title}</span>
|
||||
<span style={{ margin: '0 3px' }}>•</span>
|
||||
<span>{relativeDate}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{!entry.is_read && <div className="unread-dot" />}
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: entry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleStar(entry, e);
|
||||
}}
|
||||
title={entry.is_starred ? 'Unstar' : 'Star'}
|
||||
>
|
||||
<Star
|
||||
size={13}
|
||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full-Screen Reading View with Back Arrow (Xiaohongshu post reader UX) */}
|
||||
{selectedEntry && (
|
||||
<div className="mobile-post-reader">
|
||||
{/* Top Bar with Back Arrow */}
|
||||
<div className="mobile-post-reader-header">
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 34, height: 34 }}
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
title="Back to feed"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
maxWidth: '40vw',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Sliding Ball Capsule Toggle Button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`capsule-switch-btn ${
|
||||
selectedVariantKey === 'readability_full_text' ? 'active' : ''
|
||||
} ${isScraping ? 'scraping' : ''}`}
|
||||
onClick={() => {
|
||||
if (isScraping) return;
|
||||
if (selectedVariantKey === 'readability_full_text') {
|
||||
setSelectedVariantKey('original');
|
||||
} else {
|
||||
if (hasReadability) {
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} else {
|
||||
handleScrapeFullText();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
selectedVariantKey === 'readability_full_text'
|
||||
? 'Switch to Original RSS content'
|
||||
: hasReadability
|
||||
? 'Switch to Clean Full Text'
|
||||
: 'Scrape and view Clean Full Text'
|
||||
}
|
||||
>
|
||||
<div className="capsule-switch-track">
|
||||
<span className="capsule-switch-label">
|
||||
{isScraping
|
||||
? 'Scraping...'
|
||||
: selectedVariantKey === 'readability_full_text'
|
||||
? 'Full Text'
|
||||
: 'Original'}
|
||||
</span>
|
||||
<div className="capsule-switch-thumb">
|
||||
{isScraping ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : selectedVariantKey === 'readability_full_text' ? (
|
||||
<Zap size={12} />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasAISummary && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
||||
style={{
|
||||
border: '1px solid',
|
||||
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.7rem',
|
||||
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
||||
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
||||
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
}}
|
||||
onClick={() => {
|
||||
const next = !showAISummary;
|
||||
setShowAISummary(next);
|
||||
if (next) {
|
||||
setTimeout(() => {
|
||||
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
||||
>
|
||||
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
||||
<span>Summary</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasTTS && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.7rem',
|
||||
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('tts')}
|
||||
title="Audio Stream"
|
||||
>
|
||||
<Volume2 size={12} style={{ marginRight: 3 }} />
|
||||
Audio
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleStar(selectedEntry)}
|
||||
title={selectedEntry.is_starred ? 'Unstar' : 'Star'}
|
||||
>
|
||||
<Star
|
||||
size={16}
|
||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleRead(selectedEntry)}
|
||||
title={selectedEntry.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-icon"
|
||||
title="Share Article"
|
||||
>
|
||||
<Share2 size={15} />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-icon"
|
||||
title="Open Original"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Body */}
|
||||
<div className="mobile-post-reader-content" ref={readerScrollRef}>
|
||||
<div className="reader-content-canvas" style={{ padding: 0 }}>
|
||||
<div className="article-header">
|
||||
<div className="article-header-meta">
|
||||
<span className="article-feed-badge">
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
<span className="article-meta-divider">•</span>
|
||||
<span className="article-meta-item">
|
||||
<Clock size={12} />
|
||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="article-meta-divider">•</span>
|
||||
<span className="article-meta-item">{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span className="article-meta-divider">•</span>
|
||||
<span className="article-meta-item">
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{getEntryTags(selectedEntry.variants).length > 0 && (
|
||||
<div className="article-tags-row">
|
||||
{getEntryTags(selectedEntry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Summary Card if present and visible */}
|
||||
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
||||
<div
|
||||
ref={summaryCardRef}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.05)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.22)',
|
||||
borderRadius: 10,
|
||||
padding: '1rem 1.15rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
||||
<Sparkles size={15} />
|
||||
<span>AI Summary & Takeaways</span>
|
||||
{selectedEntry.variants.ai_summary.metadata?.model && (
|
||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Copy Summary"
|
||||
onClick={() => {
|
||||
if (selectedEntry.variants?.ai_summary?.text_value) {
|
||||
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Hide Summary"
|
||||
onClick={() => setShowAISummary(false)}
|
||||
>
|
||||
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Content Variant Rendering */}
|
||||
{selectedVariantKey === 'tts' && hasTTS ? (
|
||||
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
|
||||
<audio
|
||||
controls
|
||||
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getRenderedContent(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed } from '../../types';
|
||||
import { api } from '../../services/api';
|
||||
import { X, Plus, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface AddFeedModalProps {
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
onFeedAdded: (feed: Feed) => void;
|
||||
}
|
||||
|
||||
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
|
||||
categories,
|
||||
onClose,
|
||||
onFeedAdded,
|
||||
}) => {
|
||||
const [feedUrl, setFeedUrl] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [iconUrl, setIconUrl] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [newCatTitle, setNewCatTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!feedUrl.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let finalCatId: string | undefined = categoryId || undefined;
|
||||
|
||||
// If user typed a new category name
|
||||
if (newCatTitle.trim()) {
|
||||
const newCat = await api.createCategory(newCatTitle.trim());
|
||||
finalCatId = newCat.id;
|
||||
}
|
||||
|
||||
const newFeed = await api.createFeed(
|
||||
feedUrl.trim(),
|
||||
finalCatId,
|
||||
title.trim() || undefined,
|
||||
iconUrl.trim() || undefined
|
||||
);
|
||||
|
||||
onFeedAdded(newFeed);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add feed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Add Subscription</h3>
|
||||
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--danger-soft)',
|
||||
color: 'var(--danger)',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label">Feed or Website URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
className="settings-input"
|
||||
value={feedUrl}
|
||||
onChange={(e) => setFeedUrl(e.target.value)}
|
||||
placeholder="https://news.ycombinator.com/rss"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label">Custom Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Auto-detect from feed title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label">Custom Favicon URL (Optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
className="settings-input"
|
||||
value={iconUrl}
|
||||
onChange={(e) => setIconUrl(e.target.value)}
|
||||
placeholder="Auto-detected from website domain if empty"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label">Category / Folder</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">(No Category)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label">Or Create New Category</label>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={newCatTitle}
|
||||
onChange={(e) => setNewCatTitle(e.target.value)}
|
||||
placeholder="e.g. AI & Tech"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading || !feedUrl.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spinning" /> Subscribing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={14} /> Subscribe
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { SearchHistoryItem } from '../../types';
|
||||
import { api } from '../../services/api';
|
||||
import {
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
History,
|
||||
X,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
search: string;
|
||||
onSearchChange: (val: string) => void;
|
||||
isRefreshing: boolean;
|
||||
onRefreshAll: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
isRefreshing,
|
||||
onRefreshAll,
|
||||
onOpenAddFeed,
|
||||
onOpenSettings,
|
||||
onOpenShortcuts,
|
||||
onLogout,
|
||||
}) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [historyItems, setHistoryItems] = useState<SearchHistoryItem[]>([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
|
||||
// Load search history when input is focused or query changes
|
||||
const loadHistory = async (query?: string) => {
|
||||
try {
|
||||
const items = await api.getSearchHistory(query, 8);
|
||||
setHistoryItems(items || []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showDropdown) {
|
||||
loadHistory(search);
|
||||
}
|
||||
}, [search, showDropdown]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
searchContainerRef.current &&
|
||||
!searchContainerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Global `/` shortcut for focusing search bar
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === '/' &&
|
||||
document.activeElement?.tagName !== 'INPUT' &&
|
||||
document.activeElement?.tagName !== 'TEXTAREA'
|
||||
) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
setShowDropdown(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleSelectKeyword = async (keyword: string) => {
|
||||
onSearchChange(keyword);
|
||||
setShowDropdown(false);
|
||||
try {
|
||||
await api.recordSearch(keyword);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDownInSearch = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyItems.length > 0) {
|
||||
setSelectedIndex((prev) => (prev + 1) % historyItems.length);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (historyItems.length > 0) {
|
||||
setSelectedIndex((prev) => (prev <= 0 ? historyItems.length - 1 : prev - 1));
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && historyItems[selectedIndex]) {
|
||||
handleSelectKeyword(historyItems[selectedIndex].keyword);
|
||||
} else if (search.trim()) {
|
||||
setShowDropdown(false);
|
||||
try {
|
||||
await api.recordSearch(search.trim());
|
||||
loadHistory();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteHistoryItem = async (e: React.MouseEvent, keyword: string) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteSearchHistory(keyword);
|
||||
setHistoryItems((prev) => prev.filter((item) => item.keyword !== keyword));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllHistory = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteSearchHistory();
|
||||
setHistoryItems([]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
{/* Leftmost: Brand Icon & Name */}
|
||||
<div className="header-left">
|
||||
<div className="brand-badge" title="RSSPipe Gateway">
|
||||
<Rss className="brand-icon" />
|
||||
<span>RSSPipe</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Autocomplete-Enabled Search Bar */}
|
||||
<div className="search-container" ref={searchContainerRef}>
|
||||
<Search size={15} className="search-icon-inside" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
setSelectedIndex(-1);
|
||||
if (!showDropdown) setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
loadHistory(search);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDownInSearch}
|
||||
placeholder="Search feeds, titles, or authors..."
|
||||
/>
|
||||
{search ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0.5rem',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
onSearchChange('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="search-shortcut-badge">/</span>
|
||||
)}
|
||||
|
||||
{/* Search Autocomplete History Dropdown */}
|
||||
{showDropdown && historyItems.length > 0 && (
|
||||
<div className="search-autocomplete-menu">
|
||||
<div className="search-autocomplete-header">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<Clock size={12} /> Recent Searches
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAllHistory}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{historyItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.keyword}
|
||||
className={`search-autocomplete-item ${idx === selectedIndex ? 'active' : ''}`}
|
||||
onClick={() => handleSelectKeyword(item.keyword)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem', minWidth: 0, flex: 1 }}>
|
||||
<History size={13} color="var(--text-muted)" />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.keyword}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="search-autocomplete-delete-btn"
|
||||
onClick={(e) => handleDeleteHistoryItem(e, item.keyword)}
|
||||
title="Remove from history"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions & Settings */}
|
||||
<div className="header-right">
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={onOpenAddFeed}
|
||||
title="Add new subscription (A)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onRefreshAll}
|
||||
title="Refresh feeds (R)"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={15} className={isRefreshing ? 'spinning' : ''} />
|
||||
</button>
|
||||
|
||||
{/* Settings Button */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenSettings}
|
||||
title="Settings & Subscriptions (3)"
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenShortcuts}
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<HelpCircle size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onLogout}
|
||||
title="Sign Out"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { X, Keyboard } from 'lucide-react';
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const shortcuts = [
|
||||
{ key: 'j / ↓', desc: 'Navigate to Next article' },
|
||||
{ key: 'k / ↑', desc: 'Navigate to Previous article' },
|
||||
{ key: 's', desc: 'Star / Unstar selected article' },
|
||||
{ key: 'm / r', desc: 'Toggle Read / Unread status' },
|
||||
{ key: 'f', desc: 'Scrape clean full-text via Readability' },
|
||||
{ key: 'o / v', desc: 'Open original article in new tab' },
|
||||
{ key: '[', desc: 'Collapse / Expand Sidebar' },
|
||||
{ key: 'V', desc: 'Toggle View Mode (3-Split ↔ Waterfall)' },
|
||||
{ key: 'Shift + A', desc: 'Mark all items in view as read' },
|
||||
{ key: '/', desc: 'Focus global search bar' },
|
||||
{ key: 'R', desc: 'Refresh all feed subscriptions' },
|
||||
{ key: 'A', desc: 'Open Add Feed modal' },
|
||||
{ key: '?', desc: 'Show keyboard shortcuts cheat sheet' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Keyboard size={18} color="var(--accent-primary)" />
|
||||
<h3 className="modal-title">Keyboard Shortcuts</h3>
|
||||
</div>
|
||||
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{shortcuts.map((sc, i) => (
|
||||
<div key={i} className="shortcut-row">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{sc.desc}</span>
|
||||
<span className="kbd-badge">{sc.key}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../../services/api';
|
||||
import { User } from '../../types';
|
||||
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface LoginViewProps {
|
||||
onLoginSuccess: (user: User) => void;
|
||||
}
|
||||
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('admin123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.login(username, password);
|
||||
onLoginSuccess(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
width: '100vw',
|
||||
background: 'var(--bg-app)',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
padding: '2.5rem',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.35rem' }}>
|
||||
<Rss size={28} color="var(--accent-primary)" />
|
||||
<h1 style={{ fontSize: '1.45rem', fontWeight: 800, color: 'var(--text-primary)' }}>RSSPipe</h1>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
|
||||
Minimalist Feed & Information Engine
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--danger-soft)',
|
||||
color: 'var(--danger)',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={13} /> Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-row">
|
||||
<label className="settings-label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Lock size={13} /> Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="settings-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', height: '38px', marginTop: '0.5rem', fontSize: '0.9rem' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spinning" /> Signing In...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Sign In</span>
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
Default credentials: <code>admin</code> / <code>admin123456</code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { X, UploadCloud, Download, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { api } from '../../services/api';
|
||||
import { X, UploadCloud, Download, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface OPMLModalProps {
|
||||
onClose: () => void;
|
||||
@@ -56,61 +56,70 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>OPML Migration & Backup</h3>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
<h3 className="modal-title">OPML Migration</h3>
|
||||
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--danger-soft)',
|
||||
color: 'var(--danger)',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(52, 211, 153, 0.15)',
|
||||
border: '1px solid var(--success)',
|
||||
color: 'var(--success)',
|
||||
padding: '0.85rem',
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
marginBottom: '1.25rem',
|
||||
background: 'var(--success-soft)',
|
||||
color: 'var(--success)',
|
||||
fontSize: '0.82rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: '0.875rem',
|
||||
gap: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
<CheckCircle2 size={16} />
|
||||
<span>
|
||||
Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated!
|
||||
Imported {result.imported} new feeds ({result.total} parsed in OPML).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Section */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
Import Subscriptions (from Miniflux / Google Reader / OPML)
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Upload an OPML 2.0 XML file to seamlessly import all your feeds and categories.
|
||||
</p>
|
||||
|
||||
{/* Import Dropzone */}
|
||||
<div>
|
||||
<div className="settings-label" style={{ marginBottom: '0.5rem' }}>
|
||||
Import Subscriptions (OPML 2.0 XML)
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
border: '2px dashed var(--border-strong)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '2rem 1rem',
|
||||
padding: '1.75rem 1rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--bg-main)',
|
||||
transition: 'all 0.15s ease',
|
||||
background: 'var(--bg-subtle)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -120,31 +129,21 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<UploadCloud
|
||||
size={36}
|
||||
style={{ color: 'var(--accent)', margin: '0 auto 0.5rem auto' }}
|
||||
/>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>
|
||||
{file ? file.name : 'Click to browse or drag & drop OPML file'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Supports .opml or .xml
|
||||
<UploadCloud size={32} color="var(--accent-primary)" style={{ margin: '0 auto 0.5rem' }} />
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{file ? file.name : 'Click to select or drop .opml / .xml file'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={loading}
|
||||
>
|
||||
<div style={{ marginTop: '0.75rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn-primary" onClick={handleImport} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" /> Importing...
|
||||
<Loader2 size={14} className="spinning" /> Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Import {file.name}</>
|
||||
'Start Import'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -152,18 +151,25 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1.25rem' }}>
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
<div style={{ borderTop: '1px solid var(--border-subtle)', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||
<div className="settings-label" style={{ marginBottom: '0.5rem' }}>
|
||||
Export Subscriptions
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Download a complete OPML backup of all your subscribed feeds and folders.
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
Download complete backup of all feeds and categories.
|
||||
</p>
|
||||
<button className="btn-secondary" onClick={handleExport}>
|
||||
<Download size={16} /> Export OPML File
|
||||
<Download size={14} style={{ marginRight: 4 }} />
|
||||
Export OPML File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,283 @@
|
||||
import React from 'react';
|
||||
import { FeedEntry, FilterState, Feed, Category } from '../../types';
|
||||
import { formatDateOnly, stripHtml } from '../../utils';
|
||||
import {
|
||||
CheckCheck,
|
||||
LayoutList,
|
||||
AlignJustify,
|
||||
Inbox,
|
||||
Star,
|
||||
Share2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DesktopArticleListProps {
|
||||
entries: FeedEntry[];
|
||||
selectedEntryId: string | null;
|
||||
onSelectEntry: (entry: FeedEntry) => void;
|
||||
width: number;
|
||||
listMode: 'compact' | 'rich';
|
||||
onToggleListMode: (mode: 'compact' | 'rich') => void;
|
||||
onMarkAllRead: () => void;
|
||||
filter: FilterState;
|
||||
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
|
||||
feeds?: Feed[];
|
||||
categories?: Category[];
|
||||
onFilterChange?: (newFilter: Partial<FilterState>) => void;
|
||||
}
|
||||
|
||||
// Extract keywords or tags from entry variants or metadata
|
||||
const extractEntryList = (entry: FeedEntry, variantKey: string, metaKey: string): string[] => {
|
||||
const v = entry.variants?.[variantKey]?.text_value;
|
||||
if (v) {
|
||||
return v.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
const val = entry.metadata?.[metaKey];
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string' && val) {
|
||||
return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
|
||||
entries,
|
||||
selectedEntryId,
|
||||
onSelectEntry,
|
||||
width,
|
||||
listMode,
|
||||
onToggleListMode,
|
||||
onMarkAllRead,
|
||||
filter,
|
||||
onToggleStar,
|
||||
feeds = [],
|
||||
categories = [],
|
||||
onFilterChange,
|
||||
}) => {
|
||||
const getFilterTitle = () => {
|
||||
if (filter.search) return `Search: "${filter.search}"`;
|
||||
if (filter.feedId) {
|
||||
const activeFeed = feeds.find((f) => f.id === filter.feedId);
|
||||
if (activeFeed) return activeFeed.title;
|
||||
}
|
||||
if (filter.categoryId) {
|
||||
const activeCat = categories.find((c) => c.id === filter.categoryId);
|
||||
if (activeCat) return activeCat.title;
|
||||
}
|
||||
if (filter.view === 'starred') return 'Starred Articles';
|
||||
if (filter.view === 'all') return 'All Articles';
|
||||
if (filter.view === 'shared') return 'Shared Articles';
|
||||
return 'Inbox';
|
||||
};
|
||||
|
||||
const unreadCount = entries.filter((e) => !e.is_read).length;
|
||||
|
||||
return (
|
||||
<div className="desktop-list-pane" style={{ width: `${width}px` }}>
|
||||
{/* Header Bar */}
|
||||
<div className="list-header-bar">
|
||||
<div className="list-title-group">
|
||||
<span className="list-title-text" title={getFilterTitle()}>
|
||||
{getFilterTitle()}
|
||||
</span>
|
||||
{unreadCount > 0 && <span className="badge badge-unread">{unreadCount}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
{/* Unread / All Scope Switcher when browsing a specific feed/category */}
|
||||
{(filter.feedId || filter.categoryId) && onFilterChange && (
|
||||
<div className="list-scope-pill-group">
|
||||
<button
|
||||
className={`scope-pill-btn ${filter.view === 'inbox' ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ view: 'inbox' })}
|
||||
title="Show unread articles"
|
||||
>
|
||||
Unread
|
||||
</button>
|
||||
<button
|
||||
className={`scope-pill-btn ${filter.view === 'all' ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ view: 'all' })}
|
||||
title="Show all articles"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onMarkAllRead}
|
||||
title="Mark all as read in this view (Shift+A)"
|
||||
>
|
||||
<CheckCheck size={15} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="list-mode-toggle">
|
||||
<button
|
||||
className={`list-mode-btn ${listMode === 'compact' ? 'active' : ''}`}
|
||||
onClick={() => onToggleListMode('compact')}
|
||||
title="Compact 2-line list"
|
||||
>
|
||||
<AlignJustify size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`list-mode-btn ${listMode === 'rich' ? 'active' : ''}`}
|
||||
onClick={() => onToggleListMode('rich')}
|
||||
title="Expanded detailed list"
|
||||
>
|
||||
<LayoutList size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Articles Column */}
|
||||
<div className="desktop-articles-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<div className="reader-empty-state" style={{ height: '300px' }}>
|
||||
<Inbox className="reader-empty-icon" />
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
{filter.view === 'inbox' && (filter.feedId || filter.categoryId)
|
||||
? 'No unread articles in this feed'
|
||||
: 'No articles found'}
|
||||
</div>
|
||||
{filter.view === 'inbox' && (filter.feedId || filter.categoryId) && onFilterChange && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '0.75rem', fontSize: '0.8rem', padding: '0.35rem 0.75rem' }}
|
||||
onClick={() => onFilterChange({ view: 'all' })}
|
||||
>
|
||||
Switch to All Articles
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => {
|
||||
const isSelected = selectedEntryId === entry.id;
|
||||
const hasAi = Boolean(entry.variants?.['ai_summary']?.text_value);
|
||||
const isShared = Boolean(entry.share_uuid);
|
||||
|
||||
// 1. Compact 2-Line Mode
|
||||
if (listMode === 'compact') {
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`article-row-compact ${!entry.is_read ? 'unread' : ''} ${
|
||||
isSelected ? 'selected' : ''
|
||||
}`}
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
>
|
||||
<div
|
||||
className={`unread-indicator-dot ${entry.is_read ? 'read' : ''}`}
|
||||
/>
|
||||
<div className="compact-content-col">
|
||||
<div className="compact-title">{entry.title}</div>
|
||||
<div className="compact-date">{formatDateOnly(entry.published_at)}</div>
|
||||
</div>
|
||||
|
||||
<div className="compact-action-col">
|
||||
{isShared && (
|
||||
<span className="compact-icon-item" title="Publicly shared">
|
||||
<Share2 size={13} color="var(--badge-ai)" />
|
||||
</span>
|
||||
)}
|
||||
{entry.is_starred && (
|
||||
<button
|
||||
className="compact-icon-btn"
|
||||
onClick={(e) => onToggleStar(entry, e)}
|
||||
title="Starred bookmark (Click to unstar)"
|
||||
>
|
||||
<Star
|
||||
size={13}
|
||||
color="var(--star-gold)"
|
||||
fill="var(--star-gold)"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Non-Compact (Expanded) Mode: Title, Description, Date + (Starred, Shared, AI), Keywords + Tags
|
||||
const keywords = extractEntryList(entry, 'keywords', 'keywords');
|
||||
const tags = extractEntryList(entry, 'tags', 'tags');
|
||||
const hasChips = keywords.length > 0 || tags.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`article-card-rich ${!entry.is_read ? 'unread' : ''} ${
|
||||
isSelected ? 'selected' : ''
|
||||
}`}
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
>
|
||||
{/* Line 1: Title */}
|
||||
<h3 className="card-title">{entry.title}</h3>
|
||||
|
||||
{/* Line 2: Description */}
|
||||
<p className="card-snippet">
|
||||
{stripHtml(entry.content || entry.raw_content, 140)}
|
||||
</p>
|
||||
|
||||
{/* Line 3: Date (Left) & Starred, Shared, AI Summarized (Right Aligned) */}
|
||||
<div className="card-meta-status-row">
|
||||
<span className="card-date">{formatDateOnly(entry.published_at)}</span>
|
||||
|
||||
<div className="card-action-icons">
|
||||
{/* Starred */}
|
||||
{entry.is_starred && (
|
||||
<button
|
||||
className="compact-icon-btn"
|
||||
onClick={(e) => onToggleStar(entry, e)}
|
||||
title="Starred bookmark (Click to unstar)"
|
||||
>
|
||||
<Star
|
||||
size={13}
|
||||
color="var(--star-gold)"
|
||||
fill="var(--star-gold)"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Shared */}
|
||||
{isShared && (
|
||||
<span className="compact-icon-item" title="Publicly shared">
|
||||
<Share2 size={13} color="var(--badge-ai)" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AI Summarized */}
|
||||
{hasAi && (
|
||||
<span className="compact-icon-item" title="AI Summarized">
|
||||
<Sparkles size={13} color="var(--badge-ai)" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line 4: Keywords & Tags (in different colors) */}
|
||||
{hasChips && (
|
||||
<div className="card-chips-row">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={`kw-${i}`} className="card-chip card-keyword-chip">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
{tags.map((tg, i) => (
|
||||
<span key={`tg-${i}`} className="card-chip card-tag-chip">
|
||||
#{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,554 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { FeedEntry } from '../../types';
|
||||
import { api } from '../../services/api';
|
||||
import { formatDateTime, estimateReadingTime, renderMarkdownText } from '../../utils';
|
||||
import {
|
||||
Star,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Zap,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
CheckCircle2,
|
||||
Share2,
|
||||
ChevronDown,
|
||||
Headphones,
|
||||
Play,
|
||||
Pause,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DesktopReaderProps {
|
||||
entry: FeedEntry | null;
|
||||
onToggleStar: (entry: FeedEntry) => void;
|
||||
onToggleRead: (entry: FeedEntry) => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||
currentAudioUrl?: string | null;
|
||||
isPlayingAudio?: boolean;
|
||||
}
|
||||
|
||||
export const DesktopReader: React.FC<DesktopReaderProps> = ({
|
||||
entry,
|
||||
onToggleStar,
|
||||
onToggleRead,
|
||||
onUpdateEntry,
|
||||
onPlayAudio,
|
||||
currentAudioUrl,
|
||||
isPlayingAudio,
|
||||
}) => {
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
const [showActionDropdown, setShowActionDropdown] = useState(false);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const autoReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Helper to mark current entry as read if unread
|
||||
const markAsReadIfUnread = () => {
|
||||
if (entry && !entry.is_read) {
|
||||
onToggleRead(entry);
|
||||
}
|
||||
};
|
||||
|
||||
// Automatic read triggers
|
||||
useEffect(() => {
|
||||
if (autoReadTimerRef.current) {
|
||||
clearTimeout(autoReadTimerRef.current);
|
||||
autoReadTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!entry || entry.is_read) return;
|
||||
|
||||
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
|
||||
if (!isAutoReadEnabled) return;
|
||||
|
||||
// 1. If content <= 1 page on view, auto mark as read after 3 seconds
|
||||
const measureTimer = setTimeout(() => {
|
||||
const scrollEl = scrollAreaRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const isShortContent = scrollEl.scrollHeight <= scrollEl.clientHeight + 25;
|
||||
if (isShortContent) {
|
||||
autoReadTimerRef.current = setTimeout(() => {
|
||||
markAsReadIfUnread();
|
||||
}, 3000);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
clearTimeout(measureTimer);
|
||||
if (autoReadTimerRef.current) {
|
||||
clearTimeout(autoReadTimerRef.current);
|
||||
autoReadTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [entry?.id, entry?.is_read, selectedVariantKey]);
|
||||
|
||||
// 2. If click to the content view (only content, not including the right pane toolbar)
|
||||
const handleContentClick = () => {
|
||||
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
|
||||
if (!isAutoReadEnabled || !entry || entry.is_read) return;
|
||||
markAsReadIfUnread();
|
||||
};
|
||||
|
||||
// 3. If content > 1 page on view, scroll on the right pane will auto mark as read
|
||||
const handleContentScroll = () => {
|
||||
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
|
||||
if (!isAutoReadEnabled || !entry || entry.is_read) return;
|
||||
|
||||
const scrollEl = scrollAreaRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const isLongContent = scrollEl.scrollHeight > scrollEl.clientHeight + 25;
|
||||
if (isLongContent && scrollEl.scrollTop > 15) {
|
||||
markAsReadIfUnread();
|
||||
}
|
||||
};
|
||||
|
||||
// Sync selected variant and clear transient errors when switching articles
|
||||
useEffect(() => {
|
||||
setAiError(null);
|
||||
if (entry?.variants?.['readability_full_text']) {
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} else {
|
||||
setSelectedVariantKey('original');
|
||||
}
|
||||
}, [entry?.id]);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowActionDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
<div className="desktop-reader-pane">
|
||||
<div className="reader-empty-state">
|
||||
<FileText className="reader-empty-icon" />
|
||||
<div style={{ fontSize: '1.05rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
No Article Selected
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Choose an article from the list or press <span className="kbd-badge">j</span> / <span className="kbd-badge">k</span> to navigate
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const variants = entry.variants || {};
|
||||
const hasReadability = Boolean(variants['readability_full_text']?.text_value);
|
||||
const hasAISummary = Boolean(variants['ai_summary']?.text_value);
|
||||
const ttsVariant = variants['tts'] || variants['podcast_tts'];
|
||||
const isShared = Boolean(entry.share_uuid);
|
||||
|
||||
// Extract keywords / tags from variants or metadata
|
||||
const extractList = (variantKey: string, metaKey: string): string[] => {
|
||||
if (variants[variantKey]?.text_value) {
|
||||
return variants[variantKey].text_value
|
||||
.split(/[,,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
const val = entry.metadata?.[metaKey];
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string' && val) {
|
||||
return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const keywords = extractList('keywords', 'keywords');
|
||||
const tags = extractList('tags', 'tags');
|
||||
|
||||
// Handle Scrape Full Text
|
||||
const handleScrapeFullText = async () => {
|
||||
if (!entry || isScraping) return;
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const updated = await api.scrapeEntry(entry.id);
|
||||
onUpdateEntry?.(updated);
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} catch (err: any) {
|
||||
setAiError(`Full text extraction failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Primary Split Action (Toggle between Full Text and Original)
|
||||
const handlePrimaryActionClick = () => {
|
||||
if (!hasReadability) {
|
||||
handleScrapeFullText();
|
||||
} else if (selectedVariantKey === 'readability_full_text') {
|
||||
setSelectedVariantKey('original');
|
||||
} else {
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle On-Demand AI Summary Generation
|
||||
const handleGenerateAISummary = async () => {
|
||||
setShowActionDropdown(false);
|
||||
if (!entry || isGeneratingAI) return;
|
||||
setIsGeneratingAI(true);
|
||||
setAiError(null);
|
||||
try {
|
||||
const updated = await api.generateAISummary(entry.id);
|
||||
onUpdateEntry?.(updated);
|
||||
} catch (err: any) {
|
||||
setAiError(err.message || 'AI generation failed. Please check AI settings.');
|
||||
} finally {
|
||||
setIsGeneratingAI(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Toggle Public Share
|
||||
const handleToggleShare = async () => {
|
||||
if (!entry || isSharing) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
const res = await api.toggleShare(entry.id);
|
||||
onUpdateEntry?.(res.entry);
|
||||
if (res.shared && res.share_uuid) {
|
||||
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 2500);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAiError(`Sharing failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (!entry.url) return;
|
||||
navigator.clipboard.writeText(entry.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const getRenderedContent = () => {
|
||||
if (selectedVariantKey === 'readability_full_text' && variants['readability_full_text']?.text_value) {
|
||||
return variants['readability_full_text'].text_value;
|
||||
}
|
||||
return entry.content || entry.raw_content || '';
|
||||
};
|
||||
|
||||
const isCurrentAudioActive = Boolean(
|
||||
ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="desktop-reader-pane">
|
||||
{/* Top Action Toolbar */}
|
||||
<div className="reader-toolbar">
|
||||
{/* Left Toolbar Actions */}
|
||||
<div className="reader-toolbar-left">
|
||||
{/* 1. Toggle Read/Unread */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onToggleRead(entry)}
|
||||
title={entry.is_read ? 'Mark as unread (m)' : 'Mark as read (m)'}
|
||||
>
|
||||
<CheckCircle2
|
||||
size={15}
|
||||
color={entry.is_read ? 'var(--text-dim)' : 'var(--accent-primary)'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 2. Toggle Star */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => onToggleStar(entry)}
|
||||
title={entry.is_starred ? 'Unstar bookmark (s)' : 'Star bookmark (s)'}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
color={entry.is_starred ? 'var(--star-gold)' : 'var(--text-muted)'}
|
||||
fill={entry.is_starred ? 'var(--star-gold)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 3. Toggle Shared */}
|
||||
<button
|
||||
className={`btn-icon ${isShared ? 'active' : ''}`}
|
||||
onClick={handleToggleShare}
|
||||
disabled={isSharing}
|
||||
title={isShared ? 'Publicly Shared (Click to unshare / copy link)' : 'Share public link'}
|
||||
style={{ color: isShared ? 'var(--badge-ai)' : 'var(--text-muted)' }}
|
||||
>
|
||||
{isSharing ? (
|
||||
<Loader2 size={15} className="spinning" />
|
||||
) : (
|
||||
<Share2 size={15} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 4. Copy Link to Pasteboard */}
|
||||
<button className="btn-icon" onClick={handleCopyLink} title="Copy link to clipboard">
|
||||
<Copy size={15} color={copied ? 'var(--success)' : 'var(--text-muted)'} />
|
||||
</button>
|
||||
|
||||
{/* 5. Go to Original Link */}
|
||||
<a
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-icon"
|
||||
title="Open original website in new tab (o)"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Right Toolbar: Split Action Button & Actions Dropdown */}
|
||||
<div className="reader-toolbar-right">
|
||||
{shareCopied && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--success)', fontWeight: 600, marginRight: '0.25rem' }}>
|
||||
✓ Share link copied!
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="split-action-container" ref={dropdownRef}>
|
||||
{/* Primary Action Button */}
|
||||
<button
|
||||
className="split-action-main-btn"
|
||||
onClick={handlePrimaryActionClick}
|
||||
disabled={isScraping}
|
||||
title={
|
||||
!hasReadability
|
||||
? 'Scrape clean full-text via Readability'
|
||||
: selectedVariantKey === 'readability_full_text'
|
||||
? 'Switch back to original RSS feed'
|
||||
: 'Switch to clean full-text'
|
||||
}
|
||||
>
|
||||
{isScraping ? (
|
||||
<>
|
||||
<Loader2 size={13} className="spinning" />
|
||||
<span>Scraping...</span>
|
||||
</>
|
||||
) : !hasReadability ? (
|
||||
<>
|
||||
<Zap size={13} />
|
||||
<span>Get Full Text</span>
|
||||
</>
|
||||
) : selectedVariantKey === 'readability_full_text' ? (
|
||||
<>
|
||||
<FileText size={13} />
|
||||
<span>Original Feed</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={13} />
|
||||
<span>Full Text (Clean)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Split Dropdown Trigger */}
|
||||
<button
|
||||
className="split-action-arrow-btn"
|
||||
onClick={() => setShowActionDropdown(!showActionDropdown)}
|
||||
title="More actions"
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu (Only AI Summary and Audio Generation) */}
|
||||
{showActionDropdown && (
|
||||
<div className="split-dropdown-menu">
|
||||
{/* 1. Generate AI Summary */}
|
||||
<button
|
||||
className="split-dropdown-item"
|
||||
onClick={handleGenerateAISummary}
|
||||
disabled={isGeneratingAI}
|
||||
>
|
||||
{isGeneratingAI ? (
|
||||
<Loader2 size={14} className="spinning" color="var(--badge-ai)" />
|
||||
) : (
|
||||
<Sparkles size={14} color="var(--badge-ai)" />
|
||||
)}
|
||||
<span>{isGeneratingAI ? 'Generating AI Summary...' : 'Generate AI Summary'}</span>
|
||||
</button>
|
||||
|
||||
{/* 2. Generate Audio (Placeholder) */}
|
||||
<button
|
||||
className="split-dropdown-item"
|
||||
onClick={() => {
|
||||
setShowActionDropdown(false);
|
||||
alert('Audio narration synthesis is in development and will be available soon!');
|
||||
}}
|
||||
>
|
||||
<Headphones size={14} color="var(--badge-tts)" />
|
||||
<span>Generate Audio</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reader Scrollable Container (Full width, scrollbar tracks on the far right) */}
|
||||
<div
|
||||
className="reader-scroll-area"
|
||||
ref={scrollAreaRef}
|
||||
onScroll={handleContentScroll}
|
||||
onClick={handleContentClick}
|
||||
>
|
||||
<div className="reader-content-inner">
|
||||
{/* Header Section */}
|
||||
<div className="reader-header">
|
||||
{/* 1. Secondary Title: Feed Title */}
|
||||
<div className="reader-feed-subtitle" style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||
{entry.feed_site_url && (
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${entry.feed_site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`}
|
||||
alt=""
|
||||
style={{ width: '14px', height: '14px', borderRadius: '2px', objectFit: 'contain', flexShrink: 0 }}
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
<span>{entry.feed_title}</span>
|
||||
</div>
|
||||
|
||||
{/* 2. Post Title */}
|
||||
<h1 className="reader-article-title">{entry.title}</h1>
|
||||
|
||||
{/* 3. Author (if exists) */}
|
||||
{entry.author && (
|
||||
<div className="reader-meta-line">
|
||||
<UserIcon size={14} className="reader-meta-icon" />
|
||||
<span className="reader-author-name">{entry.author}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. Updated Date (YYYY-MM-DD HH:mm) & Time to Read */}
|
||||
<div className="reader-meta-line">
|
||||
<Clock size={14} className="reader-meta-icon" />
|
||||
<span>{formatDateTime(entry.published_at)}</span>
|
||||
<span className="reader-meta-dot">•</span>
|
||||
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
||||
</div>
|
||||
|
||||
{/* 5. Keywords (if exists) */}
|
||||
{keywords.length > 0 && (
|
||||
<div className="reader-chips-row">
|
||||
<span className="reader-chips-label">Keywords:</span>
|
||||
<div className="reader-chips-list">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="reader-chip">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 6. Tags (if exists) */}
|
||||
{tags.length > 0 && (
|
||||
<div className="reader-chips-row">
|
||||
<span className="reader-chips-label">Tags:</span>
|
||||
<div className="reader-chips-list">
|
||||
{tags.map((tg, i) => (
|
||||
<span key={i} className="reader-chip reader-tag-chip">
|
||||
#{tg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 7. Splitter Divider */}
|
||||
<hr className="reader-header-divider" />
|
||||
|
||||
{/* Transient AI Error Message Block (In-page error on summary area) */}
|
||||
{aiError && (
|
||||
<div className="desktop-ai-summary-box ai-error-box">
|
||||
<div className="ai-summary-header" style={{ color: 'var(--danger)', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<AlertCircle size={16} />
|
||||
<span>AI Action Notice</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAiError(null)}
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
title="Dismiss error"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ai-summary-body" style={{ color: 'var(--text-primary)', fontSize: '0.85rem' }}>
|
||||
{aiError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 8. AI Executive Summary (if exists) */}
|
||||
{hasAISummary && (
|
||||
<div className="desktop-ai-summary-box">
|
||||
<div className="ai-summary-header">
|
||||
<Sparkles size={16} />
|
||||
<span>AI Executive Summary</span>
|
||||
</div>
|
||||
<div
|
||||
className="ai-summary-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownText(variants['ai_summary'].text_value),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 9. Audio Narration Bar (if exists) */}
|
||||
{ttsVariant?.stream_url && (
|
||||
<div className="desktop-inline-audio-bar">
|
||||
<button
|
||||
className="audio-play-circle-btn"
|
||||
onClick={() => onPlayAudio?.(entry, ttsVariant.stream_url!)}
|
||||
title="Play Narration Audio"
|
||||
>
|
||||
{isCurrentAudioActive ? <Pause size={18} /> : <Play size={18} style={{ marginLeft: 2 }} />}
|
||||
</button>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{ttsVariant.variant_key === 'podcast_tts' ? 'Podcast Audio Episode' : 'Voice Narration'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
Listen to synthesized audio for this article
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 10. Article Content Body */}
|
||||
<div
|
||||
className="reader-body-rendered"
|
||||
dangerouslySetInnerHTML={{ __html: getRenderedContent() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, FilterState, Stats } from '../../types';
|
||||
import {
|
||||
Inbox,
|
||||
Star,
|
||||
Layers,
|
||||
Share2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Rss,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DesktopSidebarProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
stats: Stats | null;
|
||||
width: number;
|
||||
onOpenSettings: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
}
|
||||
|
||||
export const FeedIcon: React.FC<{ feed: Feed; size?: number }> = ({ feed, size = 15 }) => {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const iconUrl =
|
||||
feed.icon_url ||
|
||||
(feed.site_url
|
||||
? `https://www.google.com/s2/favicons?domain=${feed.site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`
|
||||
: '');
|
||||
|
||||
if (iconUrl && !imgError) {
|
||||
return (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
className="feed-favicon-img"
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '3px',
|
||||
objectFit: 'contain',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onError={() => setImgError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Rss size={size} className="sidebar-item-icon" style={{ flexShrink: 0 }} />;
|
||||
};
|
||||
|
||||
export const DesktopSidebar: React.FC<DesktopSidebarProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
stats,
|
||||
width,
|
||||
onOpenAddFeed,
|
||||
}) => {
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleCategory = (catId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedCategories((prev) => ({
|
||||
...prev,
|
||||
[catId]: !prev[catId],
|
||||
}));
|
||||
};
|
||||
|
||||
const feedsByCategory = categories.map((cat) => ({
|
||||
category: cat,
|
||||
feeds: feeds.filter((f) => f.category_id === cat.id),
|
||||
}));
|
||||
|
||||
const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
|
||||
|
||||
// Handle feed selection with safe view resolution & search reset
|
||||
const handleSelectFeed = (feed: Feed) => {
|
||||
onFilterChange({
|
||||
feedId: feed.id,
|
||||
categoryId: undefined,
|
||||
search: '',
|
||||
view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle category selection with safe view resolution & search reset
|
||||
const handleSelectCategory = (category: Category) => {
|
||||
onFilterChange({
|
||||
categoryId: category.id,
|
||||
feedId: undefined,
|
||||
search: '',
|
||||
view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="desktop-sidebar"
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
<div className="sidebar-scroll-area">
|
||||
{/* 1. General Views */}
|
||||
<div className="sidebar-section-title">Views</div>
|
||||
<div className="sidebar-nav-list">
|
||||
{/* Inbox */}
|
||||
<button
|
||||
className={`sidebar-item ${filter.view === 'inbox' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ view: 'inbox', feedId: undefined, categoryId: undefined, search: '' })}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Inbox className="sidebar-item-icon" />
|
||||
<span className="sidebar-item-title">Inbox</span>
|
||||
</div>
|
||||
{stats && stats.total_unread > 0 && (
|
||||
<span className="badge badge-unread">{stats.total_unread}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Starred */}
|
||||
<button
|
||||
className={`sidebar-item ${filter.view === 'starred' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ view: 'starred', feedId: undefined, categoryId: undefined, search: '' })}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Star className="sidebar-item-icon" />
|
||||
<span className="sidebar-item-title">Starred</span>
|
||||
</div>
|
||||
{stats && stats.total_starred > 0 && (
|
||||
<span className="badge badge-starred">{stats.total_starred}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* All Articles */}
|
||||
<button
|
||||
className={`sidebar-item ${filter.view === 'all' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ view: 'all', feedId: undefined, categoryId: undefined, search: '' })}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Layers className="sidebar-item-icon" />
|
||||
<span className="sidebar-item-title">All Articles</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Shared */}
|
||||
<button
|
||||
className={`sidebar-item ${filter.view === 'shared' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ view: 'shared', feedId: undefined, categoryId: undefined, search: '' })}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Share2 className="sidebar-item-icon" />
|
||||
<span className="sidebar-item-title">Shared</span>
|
||||
</div>
|
||||
{stats && (stats.total_shared || 0) > 0 && (
|
||||
<span className="badge badge-unread">{stats.total_shared}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Feeds & Subscriptions */}
|
||||
<div className="sidebar-section-title" style={{ marginTop: '1.15rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Feeds</span>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '20px', height: '20px', border: 'none', background: 'transparent' }}
|
||||
onClick={onOpenAddFeed}
|
||||
title="Add Feed (A)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-nav-list">
|
||||
{/* Categories */}
|
||||
{feedsByCategory.map(({ category, feeds: catFeeds }) => {
|
||||
const isCatCollapsed = Boolean(collapsedCategories[category.id]);
|
||||
const isCatActive = filter.categoryId === category.id && !filter.feedId;
|
||||
const catUnread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
|
||||
|
||||
return (
|
||||
<div key={category.id} className="sidebar-category-group">
|
||||
{/* Category Header Row */}
|
||||
<div
|
||||
className={`sidebar-item sidebar-category-header ${isCatActive ? 'active' : ''}`}
|
||||
onClick={() => handleSelectCategory(category)}
|
||||
title={category.title}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Folder className="sidebar-item-icon" />
|
||||
<span className="sidebar-item-title">{category.title}</span>
|
||||
<span
|
||||
className="category-chevron-btn"
|
||||
onClick={(e) => toggleCategory(category.id, e)}
|
||||
title={isCatCollapsed ? 'Expand folder' : 'Collapse folder'}
|
||||
>
|
||||
{isCatCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{catUnread > 0 && (
|
||||
<span className="badge badge-unread">{catUnread}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feeds in Category */}
|
||||
{!isCatCollapsed && (
|
||||
<div className="sidebar-category-children">
|
||||
{catFeeds.map((feed) => {
|
||||
const isFeedActive = filter.feedId === feed.id;
|
||||
return (
|
||||
<button
|
||||
key={feed.id}
|
||||
className={`sidebar-item ${isFeedActive ? 'active' : ''}`}
|
||||
onClick={() => handleSelectFeed(feed)}
|
||||
title={feed.title}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<FeedIcon feed={feed} size={15} />
|
||||
<span className="sidebar-item-title">{feed.title}</span>
|
||||
</div>
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Uncategorized Feeds */}
|
||||
{uncategorizedFeeds.map((feed) => {
|
||||
const isFeedActive = filter.feedId === feed.id;
|
||||
return (
|
||||
<button
|
||||
key={feed.id}
|
||||
className={`sidebar-item ${isFeedActive ? 'active' : ''}`}
|
||||
onClick={() => handleSelectFeed(feed)}
|
||||
title={feed.title}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<FeedIcon feed={feed} size={15} />
|
||||
<span className="sidebar-item-title">{feed.title}</span>
|
||||
</div>
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface ResizableSplitterProps {
|
||||
onResize: (deltaX: number) => void;
|
||||
onDoubleClick?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ResizableSplitter: React.FC<ResizableSplitterProps> = ({
|
||||
onResize,
|
||||
onDoubleClick,
|
||||
title = 'Drag to resize columns',
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setStartX(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const deltaX = e.clientX - startX;
|
||||
if (deltaX !== 0) {
|
||||
onResize(deltaX);
|
||||
setStartX(e.clientX);
|
||||
}
|
||||
},
|
||||
[isDragging, startX, onResize]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`pane-splitter ${isDragging ? 'is-dragging' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
title={title}
|
||||
/>
|
||||
{isDragging && <div className="splitter-drag-overlay" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../../types';
|
||||
import { DesktopSidebar } from './DesktopSidebar';
|
||||
import { DesktopArticleList } from './DesktopArticleList';
|
||||
import { DesktopReader } from './DesktopReader';
|
||||
import { ResizableSplitter } from './ResizableSplitter';
|
||||
|
||||
interface ThreePaneViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
entries: FeedEntry[];
|
||||
selectedEntryId: string | null;
|
||||
onSelectEntry: (entry: FeedEntry) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleRead: (entry: FeedEntry) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
stats: Stats | null;
|
||||
onOpenSettings: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||
currentAudioUrl?: string | null;
|
||||
isPlayingAudio?: boolean;
|
||||
}
|
||||
|
||||
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
entries,
|
||||
selectedEntryId,
|
||||
onSelectEntry,
|
||||
onToggleStar,
|
||||
onToggleRead,
|
||||
onMarkAllRead,
|
||||
onUpdateEntry,
|
||||
stats,
|
||||
onOpenSettings,
|
||||
onOpenAddFeed,
|
||||
onPlayAudio,
|
||||
currentAudioUrl,
|
||||
isPlayingAudio,
|
||||
}) => {
|
||||
// Pane Widths with persistence
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_sidebar_width');
|
||||
return saved ? parseInt(saved, 10) : 240;
|
||||
});
|
||||
|
||||
const [listWidth, setListWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_list_width');
|
||||
return saved ? parseInt(saved, 10) : 380;
|
||||
});
|
||||
|
||||
const [listMode, setListMode] = useState<'compact' | 'rich'>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_desktop_list_mode');
|
||||
return saved === 'rich' ? 'rich' : 'compact';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_sidebar_width', sidebarWidth.toString());
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_list_width', listWidth.toString());
|
||||
}, [listWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_desktop_list_mode', listMode);
|
||||
}, [listMode]);
|
||||
|
||||
const handleResizeSidebar = (deltaX: number) => {
|
||||
setSidebarWidth((prev) => Math.max(180, Math.min(prev + deltaX, 420)));
|
||||
};
|
||||
|
||||
const handleResizeList = (deltaX: number) => {
|
||||
setListWidth((prev) => Math.max(260, Math.min(prev + deltaX, 600)));
|
||||
};
|
||||
|
||||
const selectedEntry = entries.find((e) => e.id === selectedEntryId) || null;
|
||||
|
||||
return (
|
||||
<div className="three-pane-container">
|
||||
{/* Pane 1: Sidebar */}
|
||||
<DesktopSidebar
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
stats={stats}
|
||||
width={sidebarWidth}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenAddFeed={onOpenAddFeed}
|
||||
/>
|
||||
|
||||
{/* Splitter 1 */}
|
||||
<ResizableSplitter
|
||||
onResize={handleResizeSidebar}
|
||||
title="Drag to resize sidebar"
|
||||
/>
|
||||
|
||||
{/* Pane 2: Article List */}
|
||||
<DesktopArticleList
|
||||
entries={entries}
|
||||
selectedEntryId={selectedEntryId}
|
||||
onSelectEntry={onSelectEntry}
|
||||
width={listWidth}
|
||||
listMode={listMode}
|
||||
onToggleListMode={setListMode}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
filter={filter}
|
||||
onToggleStar={onToggleStar}
|
||||
feeds={feeds}
|
||||
categories={categories}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* Splitter 2 */}
|
||||
<ResizableSplitter
|
||||
onResize={handleResizeList}
|
||||
title="Drag to resize article list column"
|
||||
/>
|
||||
|
||||
{/* Pane 3: Detail Reader */}
|
||||
<DesktopReader
|
||||
entry={selectedEntry}
|
||||
onToggleStar={onToggleStar}
|
||||
onToggleRead={onToggleRead}
|
||||
onUpdateEntry={onUpdateEntry}
|
||||
onPlayAudio={onPlayAudio}
|
||||
currentAudioUrl={currentAudioUrl}
|
||||
isPlayingAudio={isPlayingAudio}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { FeedEntry } from '../../types';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
X,
|
||||
ChevronDown,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DockedAudioPlayerProps {
|
||||
entry: FeedEntry | null;
|
||||
streamUrl: string | null;
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DockedAudioPlayer: React.FC<DockedAudioPlayerProps> = ({
|
||||
entry,
|
||||
streamUrl,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamUrl) return;
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
}
|
||||
const audio = audioRef.current;
|
||||
audio.src = streamUrl;
|
||||
audio.playbackRate = playbackRate;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration || 0);
|
||||
const handleEnded = () => onTogglePlay();
|
||||
|
||||
audio.addEventListener('timeupdate', updateTime);
|
||||
audio.addEventListener('loadedmetadata', updateDuration);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
|
||||
if (isPlaying) {
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', updateTime);
|
||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.pause();
|
||||
};
|
||||
}, [streamUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(() => {});
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
}, [playbackRate]);
|
||||
|
||||
if (!entry || !streamUrl) return null;
|
||||
|
||||
const handleSeek = (newTime: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = (seconds: number) => {
|
||||
if (audioRef.current) {
|
||||
const nextTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration));
|
||||
audioRef.current.currentTime = nextTime;
|
||||
setCurrentTime(nextTime);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (secs: number) => {
|
||||
if (isNaN(secs)) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Docked Mini Player (Fixed above Bottom Navigation Bar) */}
|
||||
<div className="docked-audio-player" onClick={() => setIsExpanded(true)}>
|
||||
<div className="docked-progress-line" style={{ width: `${progressPercent}%` }} />
|
||||
|
||||
<div className="docked-audio-info">
|
||||
<Volume2 size={18} color="var(--badge-tts)" style={{ flexShrink: 0 }} />
|
||||
<div className="docked-audio-title">{entry.title}</div>
|
||||
</div>
|
||||
|
||||
<div className="docked-audio-controls" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
|
||||
onClick={() => handleSkip(-15)}
|
||||
title="Rewind 15s"
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '32px', height: '32px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
|
||||
onClick={onTogglePlay}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} style={{ marginLeft: 2 }} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
|
||||
onClick={() => handleSkip(15)}
|
||||
title="Forward 15s"
|
||||
>
|
||||
<RotateCw size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '24px', height: '24px', border: 'none', background: 'transparent' }}
|
||||
onClick={onClose}
|
||||
title="Close Player"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Full Player Sheet */}
|
||||
{isExpanded && (
|
||||
<div className="audio-expanded-modal" onClick={() => setIsExpanded(false)}>
|
||||
<div className="audio-expanded-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<ChevronDown size={22} />
|
||||
</button>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||
Now Playing
|
||||
</span>
|
||||
<div style={{ width: 22 }} />
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '0.5rem 0' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '0.35rem' }}>
|
||||
{entry.title}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{entry.feed_title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrubber */}
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
onChange={(e) => handleSeek(parseFloat(e.target.value))}
|
||||
className="audio-scrubber-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Controls */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1.5rem', margin: '0.5rem 0' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '40px', height: '40px', border: 'none' }}
|
||||
onClick={() => handleSkip(-15)}
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '56px', height: '56px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{isPlaying ? <Pause size={26} /> : <Play size={26} style={{ marginLeft: 3 }} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: '40px', height: '40px', border: 'none' }}
|
||||
onClick={() => handleSkip(15)}
|
||||
>
|
||||
<RotateCw size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed Selector */}
|
||||
<div className="audio-speed-chips">
|
||||
{[0.75, 1.0, 1.25, 1.5, 2.0].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
className={`audio-speed-chip ${playbackRate === rate ? 'active' : ''}`}
|
||||
onClick={() => setPlaybackRate(rate)}
|
||||
>
|
||||
{rate}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Category, Feed } from '../../types';
|
||||
import { Rss, Folder, Plus, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface FeedsTabViewProps {
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
onSelectFeed: (feed: Feed) => void;
|
||||
onOpenAddFeed: () => void;
|
||||
}
|
||||
|
||||
export const FeedsTabView: React.FC<FeedsTabViewProps> = ({
|
||||
categories,
|
||||
feeds,
|
||||
onSelectFeed,
|
||||
onOpenAddFeed,
|
||||
}) => {
|
||||
const feedsByCategory = categories.map((cat) => ({
|
||||
category: cat,
|
||||
feeds: feeds.filter((f) => f.category_id === cat.id),
|
||||
}));
|
||||
|
||||
const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', paddingBottom: 'calc(var(--bottom-nav-height) + 1.5rem)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 800, color: 'var(--text-primary)' }}>Subscriptions</h2>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ height: '30px', padding: '0 0.65rem', fontSize: '0.78rem' }}
|
||||
onClick={onOpenAddFeed}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{feedsByCategory.map(({ category, feeds: catFeeds }) => {
|
||||
if (catFeeds.length === 0) return null;
|
||||
return (
|
||||
<div key={category.id} className="settings-section-card" style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.35rem 0.5rem', fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
|
||||
<Folder size={16} />
|
||||
<span>{category.title}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '0.4rem' }}>
|
||||
{catFeeds.map((feed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
className="sidebar-item"
|
||||
style={{ padding: '0.65rem 0.5rem' }}
|
||||
onClick={() => onSelectFeed(feed)}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Rss size={15} className="sidebar-item-icon" />
|
||||
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||
)}
|
||||
<ChevronRight size={15} color="var(--text-dim)" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{uncategorizedFeeds.length > 0 && (
|
||||
<div className="settings-section-card" style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.35rem 0.5rem', fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
|
||||
<Folder size={16} />
|
||||
<span>Uncategorized</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '0.4rem' }}>
|
||||
{uncategorizedFeeds.map((feed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
className="sidebar-item"
|
||||
style={{ padding: '0.65rem 0.5rem' }}
|
||||
onClick={() => onSelectFeed(feed)}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Rss size={15} className="sidebar-item-icon" />
|
||||
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||
)}
|
||||
<ChevronRight size={15} color="var(--text-dim)" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { FeedEntry } from '../../types';
|
||||
import { formatRelativeDate, estimateReadingTime, renderMarkdownText } from '../../utils';
|
||||
import {
|
||||
ChevronLeft,
|
||||
Star,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Pause,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MobileArticleSheetProps {
|
||||
entry: FeedEntry;
|
||||
onClose: () => void;
|
||||
onToggleStar: (entry: FeedEntry) => void;
|
||||
onToggleRead?: (entry: FeedEntry) => void;
|
||||
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||
currentAudioUrl?: string | null;
|
||||
isPlayingAudio?: boolean;
|
||||
}
|
||||
|
||||
export const MobileArticleSheet: React.FC<MobileArticleSheetProps> = ({
|
||||
entry,
|
||||
onClose,
|
||||
onToggleStar,
|
||||
onPlayAudio,
|
||||
currentAudioUrl,
|
||||
isPlayingAudio,
|
||||
}) => {
|
||||
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
||||
const [touchDeltaX, setTouchDeltaX] = useState<number>(0);
|
||||
const [isFromLeftEdge, setIsFromLeftEdge] = useState(false);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Left-edge swipe to dismiss gesture handlers
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
const startX = e.touches[0].clientX;
|
||||
// Only activate if swipe originates from within 45px of the left edge
|
||||
if (startX <= 45) {
|
||||
setIsFromLeftEdge(true);
|
||||
setTouchStartX(startX);
|
||||
setTouchDeltaX(0);
|
||||
} else {
|
||||
setIsFromLeftEdge(false);
|
||||
setTouchStartX(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (!isFromLeftEdge || touchStartX === null) return;
|
||||
const currentX = e.touches[0].clientX;
|
||||
const delta = currentX - touchStartX;
|
||||
if (delta > 0) {
|
||||
setTouchDeltaX(delta);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isFromLeftEdge && touchDeltaX > 90) {
|
||||
onClose();
|
||||
} else {
|
||||
setTouchDeltaX(0);
|
||||
}
|
||||
setIsFromLeftEdge(false);
|
||||
setTouchStartX(null);
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share && entry.url) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
});
|
||||
} catch {
|
||||
// ignore share cancellation
|
||||
}
|
||||
} else if (entry.url) {
|
||||
navigator.clipboard.writeText(entry.url);
|
||||
alert('Link copied to clipboard!');
|
||||
}
|
||||
};
|
||||
|
||||
const variants = entry.variants || {};
|
||||
const hasAISummary = Boolean(variants['ai_summary']);
|
||||
const ttsVariant = variants['tts'] || variants['podcast_tts'];
|
||||
|
||||
const isCurrentAudioActive = Boolean(
|
||||
ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
|
||||
);
|
||||
|
||||
const getArticleBody = () => {
|
||||
if (variants['readability_full_text']?.text_value) {
|
||||
return variants['readability_full_text'].text_value;
|
||||
}
|
||||
return entry.content || entry.raw_content || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="mobile-article-sheet slide-in"
|
||||
style={{
|
||||
transform: touchDeltaX > 0 ? `translateX(${touchDeltaX}px)` : undefined,
|
||||
transition: touchDeltaX > 0 ? 'none' : undefined,
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<div className="mobile-sheet-header">
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={onClose}
|
||||
title="Back to Feed"
|
||||
>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
{ttsVariant?.stream_url && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={() => onPlayAudio?.(entry, ttsVariant.stream_url!)}
|
||||
title="Play Audio"
|
||||
>
|
||||
{isCurrentAudioActive ? (
|
||||
<Pause size={18} color="var(--badge-tts)" />
|
||||
) : (
|
||||
<Volume2 size={18} color="var(--badge-tts)" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={() => onToggleStar(entry)}
|
||||
title="Star article"
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
color={entry.is_starred ? 'var(--star-gold)' : 'var(--text-muted)'}
|
||||
fill={entry.is_starred ? 'var(--star-gold)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={handleShare}
|
||||
title="Share"
|
||||
>
|
||||
<Share2 size={18} />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
title="Open original link"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Article Body */}
|
||||
<div className="mobile-sheet-scroll">
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h1 style={{ fontSize: '1.45rem', fontWeight: 800, lineHeight: 1.3, color: 'var(--text-primary)', marginBottom: '0.5rem' }}>
|
||||
{entry.title}
|
||||
</h1>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>{entry.feed_title}</span>
|
||||
<span>•</span>
|
||||
<span>{formatRelativeDate(entry.published_at)}</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary Box */}
|
||||
{hasAISummary && (
|
||||
<div className="desktop-ai-summary-box" style={{ margin: '1rem 0' }}>
|
||||
<div className="ai-summary-header">
|
||||
<Sparkles size={15} />
|
||||
<span>AI Summary</span>
|
||||
</div>
|
||||
<div
|
||||
className="ai-summary-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownText(variants['ai_summary'].text_value),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rendered HTML/Markdown Content */}
|
||||
<div
|
||||
className="reader-body-rendered"
|
||||
dangerouslySetInnerHTML={{ __html: getArticleBody() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { getSmartArtTheme, stripHtml } from '../../utils';
|
||||
|
||||
interface SmartArtCardProps {
|
||||
title: string;
|
||||
seed: string;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
export const SmartArtCard: React.FC<SmartArtCardProps> = ({ title, seed, excerpt }) => {
|
||||
const theme = getSmartArtTheme(seed);
|
||||
const textToShow = excerpt ? stripHtml(excerpt, 90) : title;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="smart-art-cover"
|
||||
style={{
|
||||
background: theme.bg,
|
||||
}}
|
||||
>
|
||||
<div className="smart-art-quote" style={{ color: theme.text }}>
|
||||
{textToShow}
|
||||
</div>
|
||||
<div className="smart-art-accent-dot" style={{ background: theme.accent }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Feed, FeedEntry } from '../../types';
|
||||
import { Star, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface StarredTabViewProps {
|
||||
entries: FeedEntry[];
|
||||
feeds: Feed[];
|
||||
onSelectFeedFilter: (feedId?: string) => void;
|
||||
onOpenAllStarred: () => void;
|
||||
}
|
||||
|
||||
export const StarredTabView: React.FC<StarredTabViewProps> = ({
|
||||
entries,
|
||||
feeds,
|
||||
onSelectFeedFilter,
|
||||
onOpenAllStarred,
|
||||
}) => {
|
||||
const starredEntries = entries.filter((e) => e.is_starred);
|
||||
|
||||
// Group starred count by feed
|
||||
const starredCountsByFeed: Record<string, number> = {};
|
||||
starredEntries.forEach((e) => {
|
||||
starredCountsByFeed[e.feed_id] = (starredCountsByFeed[e.feed_id] || 0) + 1;
|
||||
});
|
||||
|
||||
const feedsWithStarred = feeds.filter((f) => starredCountsByFeed[f.id] > 0);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', paddingBottom: 'calc(var(--bottom-nav-height) + 1.5rem)' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 800, color: 'var(--text-primary)' }}>Starred Items</h2>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||
{starredEntries.length} saved articles across {feedsWithStarred.length} sources
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-card" style={{ padding: '0.5rem' }}>
|
||||
<div
|
||||
className="sidebar-item"
|
||||
style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }}
|
||||
onClick={onOpenAllStarred}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<Star size={18} color="var(--star-gold)" fill="var(--star-gold)" />
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)' }}>
|
||||
All Starred Articles
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className="badge badge-starred">{starredEntries.length}</span>
|
||||
<ChevronRight size={16} color="var(--text-dim)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedsWithStarred.map((feed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
className="sidebar-item"
|
||||
style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }}
|
||||
onClick={() => onSelectFeedFilter(feed.id)}
|
||||
>
|
||||
<div className="sidebar-item-left">
|
||||
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className="badge badge-starred">{starredCountsByFeed[feed.id]}</span>
|
||||
<ChevronRight size={16} color="var(--text-dim)" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewPreference } from '../../types';
|
||||
import { extractFirstImage, formatRelativeDate } from '../../utils';
|
||||
import { XHSBottomNav, MobileTab } from './XHSBottomNav';
|
||||
import { SmartArtCard } from './SmartArtCard';
|
||||
import { MobileArticleSheet } from './MobileArticleSheet';
|
||||
import { DockedAudioPlayer } from './DockedAudioPlayer';
|
||||
import { FeedsTabView } from './FeedsTabView';
|
||||
import { StarredTabView } from './StarredTabView';
|
||||
import { SettingsView } from '../settings/SettingsView';
|
||||
import {
|
||||
Search,
|
||||
Headphones,
|
||||
Sparkles,
|
||||
ArrowLeft,
|
||||
X,
|
||||
Columns3,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WaterfallViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
entries: FeedEntry[];
|
||||
stats: Stats | null;
|
||||
currentUser: User;
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
onRefreshData: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||
currentAudioUrl?: string | null;
|
||||
isPlayingAudio?: boolean;
|
||||
onTogglePlayAudio?: () => void;
|
||||
onCloseAudio?: () => void;
|
||||
playingEntry?: FeedEntry | null;
|
||||
viewPreference: ViewPreference;
|
||||
onChangeViewPreference: (pref: ViewPreference) => void;
|
||||
onSwitchToDesktop?: () => void;
|
||||
}
|
||||
|
||||
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
entries,
|
||||
stats,
|
||||
currentUser,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onRefreshData,
|
||||
onOpenAddFeed,
|
||||
onPlayAudio,
|
||||
currentAudioUrl,
|
||||
isPlayingAudio,
|
||||
onTogglePlayAudio,
|
||||
onCloseAudio,
|
||||
playingEntry,
|
||||
viewPreference,
|
||||
onChangeViewPreference,
|
||||
onSwitchToDesktop,
|
||||
}) => {
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>('home');
|
||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSearchInput, setShowSearchInput] = useState(false);
|
||||
|
||||
const handleCardClick = (entry: FeedEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
if (!entry.is_read) {
|
||||
onToggleRead(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onFilterChange({ search: searchQuery.trim() });
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setShowSearchInput(false);
|
||||
onFilterChange({ search: '' });
|
||||
};
|
||||
|
||||
// Filter entries based on active tab and search
|
||||
let displayedEntries = entries;
|
||||
if (mobileTab === 'starred') {
|
||||
displayedEntries = entries.filter((e) => e.is_starred);
|
||||
} else if (mobileTab === 'listen') {
|
||||
displayedEntries = entries.filter(
|
||||
(e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
|
||||
);
|
||||
}
|
||||
|
||||
// Split into 2 columns for Masonry layout
|
||||
const col1: FeedEntry[] = [];
|
||||
const col2: FeedEntry[] = [];
|
||||
displayedEntries.forEach((entry, idx) => {
|
||||
if (idx % 2 === 0) col1.push(entry);
|
||||
else col2.push(entry);
|
||||
});
|
||||
|
||||
const playableCount = entries.filter(
|
||||
(e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
|
||||
).length;
|
||||
|
||||
const currentFeedFilter = feeds.find((f) => f.id === filter.feedId);
|
||||
|
||||
return (
|
||||
<div className="waterfall-app-root">
|
||||
{/* Mobile Top Header */}
|
||||
<header className="mobile-top-header">
|
||||
{currentFeedFilter ? (
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={() => onFilterChange({ feedId: undefined })}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="mobile-header-title">RSSPipe</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flex: showSearchInput ? 1 : 'none', marginLeft: showSearchInput ? '0.5rem' : 0 }}>
|
||||
{showSearchInput ? (
|
||||
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.3rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search articles..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={clearSearch}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={() => setShowSearchInput(true)}
|
||||
title="Search"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
|
||||
{onSwitchToDesktop && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
onClick={onSwitchToDesktop}
|
||||
title="Switch to 3-Split Desktop View (V)"
|
||||
>
|
||||
<Columns3 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Top Category Pill Tabs (In Home View) */}
|
||||
{mobileTab === 'home' && !filter.feedId && (
|
||||
<div className="mobile-tabs-pill-bar">
|
||||
<button
|
||||
className={`mobile-tab-pill ${!filter.categoryId && filter.view === 'inbox' ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ categoryId: undefined, view: 'inbox' })}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`mobile-tab-pill ${filter.categoryId === cat.id ? 'active' : ''}`}
|
||||
onClick={() => onFilterChange({ categoryId: cat.id, view: 'all' })}
|
||||
>
|
||||
{cat.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Tab Content */}
|
||||
{mobileTab === 'feeds' ? (
|
||||
<FeedsTabView
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
onSelectFeed={(feed) => {
|
||||
onFilterChange({ feedId: feed.id, view: 'all' });
|
||||
setMobileTab('home');
|
||||
}}
|
||||
onOpenAddFeed={onOpenAddFeed}
|
||||
/>
|
||||
) : mobileTab === 'starred' ? (
|
||||
<StarredTabView
|
||||
entries={entries.filter((e) => e.is_starred)}
|
||||
feeds={feeds}
|
||||
onSelectFeedFilter={(feedId) => {
|
||||
onFilterChange({ feedId, view: 'starred' });
|
||||
setMobileTab('home');
|
||||
}}
|
||||
onOpenAllStarred={() => {
|
||||
onFilterChange({ view: 'starred', feedId: undefined });
|
||||
setMobileTab('home');
|
||||
}}
|
||||
/>
|
||||
) : mobileTab === 'settings' ? (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<SettingsView
|
||||
currentUser={currentUser}
|
||||
feeds={feeds}
|
||||
categories={categories}
|
||||
stats={stats}
|
||||
onRefreshData={onRefreshData}
|
||||
onOpenAddFeed={onOpenAddFeed}
|
||||
onBack={() => setMobileTab('home')}
|
||||
viewPreference={viewPreference}
|
||||
onChangeViewPreference={onChangeViewPreference}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Waterfall 2-Column Grid (Home / Listen / Filtered Feeds) */
|
||||
<div className="waterfall-scroll-area">
|
||||
{displayedEntries.length === 0 ? (
|
||||
<div className="reader-empty-state" style={{ height: '300px' }}>
|
||||
<div style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
No posts found
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||
Pull to refresh or check other categories
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="waterfall-masonry-grid">
|
||||
{/* Column 1 */}
|
||||
<div className="waterfall-column">
|
||||
{col1.map((entry) => renderCard(entry))}
|
||||
</div>
|
||||
{/* Column 2 */}
|
||||
<div className="waterfall-column">
|
||||
{col2.map((entry) => renderCard(entry))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Docked Mini Audio Player (when audio is active) */}
|
||||
{currentAudioUrl && (
|
||||
<DockedAudioPlayer
|
||||
entry={playingEntry || selectedEntry}
|
||||
streamUrl={currentAudioUrl}
|
||||
isPlaying={Boolean(isPlayingAudio)}
|
||||
onTogglePlay={() => onTogglePlayAudio?.()}
|
||||
onClose={() => onCloseAudio?.()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* iOS 5-Tab Bottom Navigation Bar */}
|
||||
<XHSBottomNav
|
||||
activeTab={mobileTab}
|
||||
onTabChange={(tab) => {
|
||||
setMobileTab(tab);
|
||||
if (tab === 'starred') {
|
||||
onFilterChange({ view: 'starred' });
|
||||
} else if (tab === 'home') {
|
||||
onFilterChange({ view: 'inbox', feedId: undefined });
|
||||
}
|
||||
}}
|
||||
stats={stats}
|
||||
playableCount={playableCount}
|
||||
/>
|
||||
|
||||
{/* Slide-In Full-Screen Article Reader Sheet with Left-Edge Swipe Dismiss */}
|
||||
{selectedEntry && (
|
||||
<MobileArticleSheet
|
||||
entry={selectedEntry}
|
||||
onClose={() => setSelectedEntry(null)}
|
||||
onToggleStar={onToggleStar}
|
||||
onToggleRead={onToggleRead}
|
||||
onPlayAudio={onPlayAudio}
|
||||
currentAudioUrl={currentAudioUrl}
|
||||
isPlayingAudio={isPlayingAudio}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderCard(entry: FeedEntry) {
|
||||
const coverImage = extractFirstImage(entry.content || entry.raw_content);
|
||||
const hasAi = Boolean(entry.variants?.['ai_summary']);
|
||||
const hasTts = Boolean(entry.variants?.['tts'] || entry.variants?.['podcast_tts']);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`xhs-card ${entry.is_read ? 'read' : ''}`}
|
||||
onClick={() => handleCardClick(entry)}
|
||||
>
|
||||
{/* Cover / SmartArt Preview */}
|
||||
{coverImage ? (
|
||||
<div className="xhs-card-image-wrap">
|
||||
<img src={coverImage} alt="" className="xhs-card-image" loading="lazy" />
|
||||
</div>
|
||||
) : (
|
||||
<SmartArtCard
|
||||
title={entry.title}
|
||||
seed={entry.feed_title || entry.id}
|
||||
excerpt={entry.content || entry.raw_content}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Card Body & Metadata */}
|
||||
<div className="xhs-card-body">
|
||||
<div className="xhs-card-title">{entry.title}</div>
|
||||
|
||||
<div className="xhs-card-footer">
|
||||
<div className="xhs-card-author-group">
|
||||
<span className="xhs-card-feed-title">{entry.feed_title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
{hasAi && <Sparkles size={12} color="var(--badge-ai)" />}
|
||||
{hasTts && <Headphones size={12} color="var(--badge-tts)" />}
|
||||
<span>{formatRelativeDate(entry.published_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Home, Rss, Headphones, Star, Settings } from 'lucide-react';
|
||||
import { Stats } from '../../types';
|
||||
|
||||
export type MobileTab = 'home' | 'feeds' | 'listen' | 'starred' | 'settings';
|
||||
|
||||
interface XHSBottomNavProps {
|
||||
activeTab: MobileTab;
|
||||
onTabChange: (tab: MobileTab) => void;
|
||||
stats: Stats | null;
|
||||
playableCount?: number;
|
||||
}
|
||||
|
||||
export const XHSBottomNav: React.FC<XHSBottomNavProps> = ({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
stats,
|
||||
playableCount = 0,
|
||||
}) => {
|
||||
return (
|
||||
<nav className="ios-bottom-nav">
|
||||
<button
|
||||
className={`bottom-nav-item ${activeTab === 'home' ? 'active' : ''}`}
|
||||
onClick={() => onTabChange('home')}
|
||||
>
|
||||
<Home className="bottom-nav-icon" />
|
||||
<span>Home</span>
|
||||
{stats && stats.total_unread > 0 && (
|
||||
<span className="bottom-nav-badge">
|
||||
{stats.total_unread > 99 ? '99+' : stats.total_unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bottom-nav-item ${activeTab === 'feeds' ? 'active' : ''}`}
|
||||
onClick={() => onTabChange('feeds')}
|
||||
>
|
||||
<Rss className="bottom-nav-icon" />
|
||||
<span>Feeds</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bottom-nav-item ${activeTab === 'listen' ? 'active' : ''}`}
|
||||
onClick={() => onTabChange('listen')}
|
||||
>
|
||||
<Headphones className="bottom-nav-icon" />
|
||||
<span>Listen</span>
|
||||
{playableCount > 0 && (
|
||||
<span className="bottom-nav-badge" style={{ background: 'var(--badge-tts)' }}>
|
||||
{playableCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bottom-nav-item ${activeTab === 'starred' ? 'active' : ''}`}
|
||||
onClick={() => onTabChange('starred')}
|
||||
>
|
||||
<Star className="bottom-nav-icon" />
|
||||
<span>Starred</span>
|
||||
{stats && stats.total_starred > 0 && (
|
||||
<span className="bottom-nav-badge" style={{ background: 'var(--star-gold)' }}>
|
||||
{stats.total_starred}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bottom-nav-item ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => onTabChange('settings')}
|
||||
>
|
||||
<Settings className="bottom-nav-icon" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+8
-1312
File diff suppressed because it is too large
Load Diff
+36
-2
@@ -97,10 +97,10 @@ export const api = {
|
||||
return request<Feed[]>('/feeds');
|
||||
},
|
||||
|
||||
async createFeed(feed_url: string, category_id?: string, title?: string): Promise<Feed> {
|
||||
async createFeed(feed_url: string, category_id?: string, title?: string, icon_url?: string): Promise<Feed> {
|
||||
return request<Feed>('/feeds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_url, category_id, title }),
|
||||
body: JSON.stringify({ feed_url, category_id, title, icon_url }),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -109,6 +109,7 @@ export const api = {
|
||||
data: {
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
icon_url?: string;
|
||||
refresh_interval_minutes?: number;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
@@ -186,6 +187,18 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async toggleShare(id: string): Promise<{ shared: boolean; share_uuid: string | null; entry: FeedEntry }> {
|
||||
return request<{ shared: boolean; share_uuid: string | null; entry: FeedEntry }>(`/entries/${id}/share`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
async generateAISummary(id: string): Promise<FeedEntry> {
|
||||
return request<FeedEntry>(`/entries/${id}/ai-summary`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// Rules
|
||||
async listRules(): Promise<any[]> {
|
||||
return request<any[]>('/rules');
|
||||
@@ -262,7 +275,28 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
},
|
||||
|
||||
// Search History
|
||||
async getSearchHistory(query?: string, limit: number = 10): Promise<import('../types').SearchHistoryItem[]> {
|
||||
const qParam = query ? `?q=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`;
|
||||
return request<import('../types').SearchHistoryItem[]>(`/search/history${qParam}`);
|
||||
},
|
||||
|
||||
async recordSearch(keyword: string): Promise<void> {
|
||||
return request<void>('/search/history', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyword }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteSearchHistory(keyword?: string): Promise<void> {
|
||||
const qParam = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
|
||||
return request<void>(`/search/history${qParam}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/* RSSPipe - Base App Styles & Global Utilities */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom Minimal Micro-Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Base App Shell */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Shared Top Navigation Header */
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
z-index: 40;
|
||||
user-select: none;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left, .header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-weight: 800;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Wide Prominent Search Bar */
|
||||
.search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 380px;
|
||||
max-width: 480px;
|
||||
flex: 0 1 420px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 2rem 0 2.2rem;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--border-focus);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.08);
|
||||
}
|
||||
|
||||
.search-icon-inside {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-shortcut-badge {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Minimal Action Buttons */
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
height: 32px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
height: 32px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Discrete View Mode Toggle */
|
||||
.discrete-view-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.discrete-view-toggle:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Spinning Animation for Refresh */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinning {
|
||||
display: inline-block !important;
|
||||
animation: spin 0.8s linear infinite !important;
|
||||
transform-origin: center center !important;
|
||||
}
|
||||
|
||||
/* Search Autocomplete Dropdown */
|
||||
.search-autocomplete-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.search-autocomplete-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-autocomplete-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-autocomplete-item:hover, .search-autocomplete-item.active {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.search-autocomplete-delete-btn {
|
||||
opacity: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-autocomplete-item:hover .search-autocomplete-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-autocomplete-delete-btn:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
/* Badge Utilities */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.badge-unread {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.badge-starred {
|
||||
background: var(--star-bg);
|
||||
color: var(--star-gold);
|
||||
}
|
||||
|
||||
.badge-ai {
|
||||
background: var(--badge-ai-soft);
|
||||
color: var(--badge-ai);
|
||||
}
|
||||
|
||||
.badge-tts {
|
||||
background: var(--badge-tts-soft);
|
||||
color: var(--badge-tts);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* RSSPipe - Shared Modal Dialog Styles */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 150;
|
||||
animation: modalFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalScaleUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes modalScaleUp {
|
||||
from { transform: scale(0.96); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.15rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
height: 32px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Shortcut Modal Table */
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.kbd-badge {
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 2px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/* RSSPipe - High-Precision Metallic Settings Styles */
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height));
|
||||
width: 100vw;
|
||||
background: var(--bg-app);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 0.75rem;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.settings-nav-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-nav-btn.active {
|
||||
background: var(--bg-card-active);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Fluid Full-Width Settings Content Pane */
|
||||
.settings-content-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2.25rem 3.5rem;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-header-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.settings-section-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.settings-section-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-input, .settings-select, .settings-textarea {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.settings-input:focus, .settings-select:focus, .settings-textarea:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.08);
|
||||
}
|
||||
|
||||
/* Feed Management List */
|
||||
.settings-feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-feed-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Prompt Template Card */
|
||||
.prompt-template-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 0.75rem;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.prompt-template-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Stats Metric Grid */
|
||||
.stats-grid-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-metric-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.15rem 1.25rem;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.stats-metric-value {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
margin-bottom: 0.35rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stats-metric-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mobile adaptation for settings */
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--bottom-nav-height);
|
||||
}
|
||||
.settings-sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.settings-nav-btn {
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
}
|
||||
.settings-content-pane {
|
||||
padding: 1.25rem 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
/* RSSPipe - Desktop 3-Split Workspace Styles */
|
||||
|
||||
.three-pane-container {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--header-height));
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
/* Splitter Gutter Handle */
|
||||
.pane-splitter {
|
||||
width: 6px;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
transition: background var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pane-splitter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 2px;
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.pane-splitter:hover::after,
|
||||
.pane-splitter.is-dragging::after {
|
||||
background: var(--accent-primary);
|
||||
width: 2px;
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.pane-splitter:hover,
|
||||
.pane-splitter.is-dragging {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Global drag overlay to prevent pointer events on iframes/text selection */
|
||||
.splitter-drag-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Pane 1: Sidebar */
|
||||
.desktop-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.desktop-sidebar.collapsed {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
border-right: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
padding: 0.75rem 1rem 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.35rem 0.55rem;
|
||||
min-height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: var(--bg-card-active);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-scroll-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-category-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-category-header:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.category-chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.category-chevron-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.sidebar-category-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Pane 2: Article List Column */
|
||||
.desktop-list-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-header-bar {
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-surface);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-title-text {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.list-scope-pill-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.scope-pill-btn {
|
||||
padding: 2px 7px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-xs);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.scope-pill-btn.active {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.list-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.list-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-mode-btn.active {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.desktop-articles-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Compact Article Row (2-Line Minimal) */
|
||||
.article-row-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
gap: 0.6rem;
|
||||
transition: var(--transition-fast);
|
||||
background: var(--bg-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.article-row-compact:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.article-row-compact.selected {
|
||||
background: var(--bg-card-active);
|
||||
}
|
||||
|
||||
.article-row-compact.unread .compact-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.unread-indicator-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unread-indicator-dot.read {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.compact-content-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.compact-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.compact-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.compact-action-col {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.compact-icon-item,
|
||||
.compact-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.compact-icon-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Non-Compact (Expanded) Article Card Mode */
|
||||
.article-card-rich {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: var(--bg-surface);
|
||||
transition: var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.article-card-rich:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.article-card-rich.selected {
|
||||
background: var(--bg-card-active);
|
||||
}
|
||||
|
||||
.article-card-rich .card-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.38;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-card-rich.unread .card-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.article-card-rich .card-snippet {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-meta-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.card-action-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.card-chips-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.card-chip {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-keyword-chip {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: #2563eb;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.card-tag-chip {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
color: #059669;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Pane 3: Detail Reader Pane */
|
||||
.desktop-reader-pane {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reader-toolbar {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
background: var(--bg-surface);
|
||||
gap: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.reader-toolbar-left, .reader-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Split Action Button & Dropdown in Reader Toolbar */
|
||||
.split-action-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.split-action-main-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-top-left-radius: var(--radius-sm);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.split-action-main-btn:hover {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.split-action-arrow-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-top-right-radius: var(--radius-sm);
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.split-action-arrow-btn:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.split-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
width: 210px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.3rem 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.split-dropdown-item:hover {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* Reader Scroll Area (Far Right Scrollbar) */
|
||||
.reader-scroll-area {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reader-content-inner {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
padding: 2.5rem 3rem;
|
||||
}
|
||||
|
||||
.reader-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.reader-feed-subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.reader-article-title {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.28;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.85rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.reader-meta-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.reader-meta-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reader-author-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reader-meta-dot {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Keywords and Tags Chips */
|
||||
.reader-chips-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reader-chips-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.reader-chips-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reader-chip {
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reader-tag-chip {
|
||||
background: rgba(24, 24, 27, 0.04);
|
||||
color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reader-header-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 1.5rem 0 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* AI Summary Box in Desktop Reader */
|
||||
.desktop-ai-summary-box {
|
||||
background: var(--badge-ai-soft);
|
||||
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.desktop-ai-summary-box.ai-error-box {
|
||||
background: var(--danger-soft, rgba(239, 68, 68, 0.08));
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.ai-summary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--badge-ai);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.ai-summary-body {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Audio Player Bar in Desktop Reader */
|
||||
.desktop-inline-audio-bar {
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.audio-play-circle-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-inverse);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.audio-play-circle-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Reader Typography Body */
|
||||
.reader-body-rendered {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.75;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reader-body-rendered p {
|
||||
margin-bottom: 1.35rem;
|
||||
}
|
||||
|
||||
.reader-body-rendered h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 2rem 0 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reader-body-rendered h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reader-body-rendered img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-md);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.reader-body-rendered blockquote {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reader-body-rendered code {
|
||||
background: var(--bg-subtle);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.reader-body-rendered pre {
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Reader Empty State */
|
||||
.reader-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reader-empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* RSSPipe - High-Precision Metallic Monochrome Design Tokens */
|
||||
|
||||
:root {
|
||||
/* Surfaces & Backgrounds (Crisp Titanium & Neutral Monochrome) */
|
||||
--bg-app: #f4f4f5;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-sidebar: #fafafa;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f4f4f5;
|
||||
--bg-card-active: #e4e4e7;
|
||||
--bg-subtle: #f4f4f5;
|
||||
--bg-overlay: rgba(9, 9, 11, 0.45);
|
||||
--bg-docked: rgba(255, 255, 255, 0.96);
|
||||
|
||||
/* Borders (Neutral Steel / Zinc) */
|
||||
--border-light: #f4f4f5;
|
||||
--border-subtle: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-focus: #18181b;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Text Colors (Pure High-Contrast Zinc) */
|
||||
--text-primary: #09090b;
|
||||
--text-secondary: #27272a;
|
||||
--text-muted: #71717a;
|
||||
--text-dim: #a1a1aa;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Brand Accents (Titanium Black & Steel) */
|
||||
--accent-primary: #18181b;
|
||||
--accent-hover: #27272a;
|
||||
--accent-soft: rgba(24, 24, 27, 0.06);
|
||||
--accent-border: #d4d4d8;
|
||||
|
||||
/* Semantic Highlight Colors */
|
||||
--star-gold: #d97706;
|
||||
--star-bg: #fef3c7;
|
||||
--success: #059669;
|
||||
--success-soft: #d1fae5;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fee2e2;
|
||||
--badge-ai: #4f46e5;
|
||||
--badge-ai-soft: #e0e7ff;
|
||||
--badge-tts: #0284c7;
|
||||
--badge-tts-soft: #e0f2fe;
|
||||
|
||||
/* Radiuses */
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows (Subtle, Crisp Elevation) */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||
--shadow-docked: 0 -4px 20px rgba(0, 0, 0, 0.07);
|
||||
|
||||
/* Layout Dimensions */
|
||||
--header-height: 50px;
|
||||
--bottom-nav-height: 58px;
|
||||
--docked-audio-height: 56px;
|
||||
--sidebar-min-width: 200px;
|
||||
--sidebar-default-width: 250px;
|
||||
--list-min-width: 300px;
|
||||
--list-default-width: 400px;
|
||||
--reader-min-width: 480px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-smooth: 350ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/* RSSPipe - Mobile Waterfall & iOS Native Experience Styles */
|
||||
|
||||
.waterfall-app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Mobile Header */
|
||||
.mobile-top-header {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
z-index: 30;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mobile-header-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Mobile Category Pill Tabs */
|
||||
.mobile-tabs-pill-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-shrink: 0;
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.mobile-tabs-pill-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-tab-pill {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-tab-pill.active {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-inverse);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Waterfall Scroll Area */
|
||||
.waterfall-scroll-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0.65rem;
|
||||
padding-bottom: calc(var(--bottom-nav-height) + var(--docked-audio-height) + 1rem);
|
||||
}
|
||||
|
||||
/* 2-Column Waterfall Masonry Grid */
|
||||
.waterfall-masonry-grid {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.waterfall-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Xiaohongshu-Style Post Card */
|
||||
.xhs-card {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xhs-card:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.xhs-card.read {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.xhs-card-image-wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.xhs-card-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 280px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* SmartArt Card (For posts without images) */
|
||||
.smart-art-cover {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 1.15rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smart-art-quote {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: var(--text-primary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.smart-art-accent-dot {
|
||||
width: 24px;
|
||||
height: 3px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Card Content Details */
|
||||
.xhs-card-body {
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.xhs-card-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text-primary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xhs-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.xhs-card-author-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xhs-card-feed-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* iOS Bottom Navigation Bar */
|
||||
.ios-bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--bottom-nav-height);
|
||||
background: var(--bg-docked);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 0.5rem;
|
||||
z-index: 50;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.bottom-nav-item.active {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.bottom-nav-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: calc(50% - 16px);
|
||||
background: var(--danger);
|
||||
color: var(--text-inverse);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-full);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile Slide-In Full-Screen Article Sheet */
|
||||
.mobile-article-sheet {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-surface);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-smooth);
|
||||
}
|
||||
|
||||
.mobile-article-sheet.slide-in {
|
||||
animation: sheetSlideIn 0.3s cubic-bezier(0.32, 0.72, 0, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes sheetSlideIn {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mobile-sheet-header {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.75rem;
|
||||
background: var(--bg-surface);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mobile-sheet-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 1.25rem 1.15rem 4rem;
|
||||
}
|
||||
|
||||
/* Docked Mini Audio Player */
|
||||
.docked-audio-player {
|
||||
position: fixed;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
bottom: calc(var(--bottom-nav-height) + 0.5rem);
|
||||
height: var(--docked-audio-height);
|
||||
background: var(--bg-docked);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-docked);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.85rem;
|
||||
z-index: 45;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.docked-audio-player:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.docked-progress-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2.5px;
|
||||
background: var(--badge-tts);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.docked-audio-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.docked-audio-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docked-audio-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Expanded Full Player Modal */
|
||||
.audio-expanded-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.audio-expanded-card {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
padding: 1.5rem 1.5rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.audio-scrubber-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audio-speed-chips {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audio-speed-chip {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audio-speed-chip.active {
|
||||
background: var(--accent-primary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
+11
-1
@@ -18,6 +18,7 @@ export interface Feed {
|
||||
title: string;
|
||||
feed_url: string;
|
||||
site_url?: string;
|
||||
icon_url?: string;
|
||||
category_id?: string;
|
||||
category_title?: string;
|
||||
is_virtual_digest: boolean;
|
||||
@@ -91,12 +92,14 @@ export interface Stats {
|
||||
total_feeds: number;
|
||||
total_unread: number;
|
||||
total_starred: number;
|
||||
total_shared?: number;
|
||||
total_categories: number;
|
||||
total_rules?: number;
|
||||
}
|
||||
|
||||
export type ViewType = 'inbox' | 'starred' | 'all';
|
||||
export type ViewType = 'inbox' | 'starred' | 'all' | 'shared';
|
||||
export type ViewMode = 'split' | 'waterfall' | 'settings';
|
||||
export type ViewPreference = 'auto' | 'mobile' | 'desktop';
|
||||
|
||||
export interface FilterState {
|
||||
view: ViewType;
|
||||
@@ -131,3 +134,10 @@ export interface AITestResult {
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface SearchHistoryItem {
|
||||
keyword: string;
|
||||
last_searched_at: string;
|
||||
search_count: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+60
-3
@@ -51,6 +51,42 @@ export function formatRelativeDate(dateStr: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format exact date & time as YYYY-MM-DD HH:mm
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||
const year = d.getFullYear();
|
||||
const month = pad(d.getMonth() + 1);
|
||||
const day = pad(d.getDate());
|
||||
const hours = pad(d.getHours());
|
||||
const mins = pad(d.getMinutes());
|
||||
return `${year}-${month}-${day} ${hours}:${mins}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format exact date as YYYY-MM-DD
|
||||
*/
|
||||
export function formatDateOnly(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||
const year = d.getFullYear();
|
||||
const month = pad(d.getMonth() + 1);
|
||||
const day = pad(d.getDate());
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate reading time in minutes
|
||||
*/
|
||||
@@ -76,6 +112,28 @@ export function getEntryTags(variants?: Record<string, { text_value?: string }>)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate elegant SmartArt background palettes based on a text seed
|
||||
*/
|
||||
const SMART_ART_PALETTES = [
|
||||
{ bg: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)', accent: '#475569', text: '#0f172a' },
|
||||
{ bg: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)', accent: '#2563eb', text: '#1e3a8a' },
|
||||
{ bg: 'linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%)', accent: '#7c3aed', text: '#4c1d95' },
|
||||
{ bg: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)', accent: '#059669', text: '#064e3b' },
|
||||
{ bg: 'linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%)', accent: '#ea580c', text: '#7c2d12' },
|
||||
{ bg: 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)', accent: '#db2777', text: '#831843' },
|
||||
{ bg: 'linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%)', accent: '#0d9488', text: '#134e4a' },
|
||||
];
|
||||
|
||||
export function getSmartArtTheme(seed: string): { bg: string; accent: string; text: string } {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const index = Math.abs(hash) % SMART_ART_PALETTES.length;
|
||||
return SMART_ART_PALETTES[index];
|
||||
}
|
||||
|
||||
export function renderMarkdownText(markdown?: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
@@ -103,7 +161,7 @@ export function renderMarkdownText(markdown?: string): string {
|
||||
let content = bulletMatch[1];
|
||||
// Format bold and code
|
||||
content = content
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-main); font-weight: 600;">$1</strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-primary); font-weight: 600;">$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
||||
output.push(`<li style="line-height: 1.55; color: var(--text-secondary);">${content}</li>`);
|
||||
} else {
|
||||
@@ -112,7 +170,7 @@ export function renderMarkdownText(markdown?: string): string {
|
||||
inList = false;
|
||||
}
|
||||
let content = trimmed
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-main); font-weight: 600;">$1</strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-primary); font-weight: 600;">$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
||||
output.push(`<p style="margin: 0.5rem 0; line-height: 1.6; color: var(--text-secondary);">${content}</p>`);
|
||||
}
|
||||
@@ -124,4 +182,3 @@ export function renderMarkdownText(markdown?: string): string {
|
||||
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user