feat: add password management, reading persistence on feed switch, and spin animation
This commit is contained in:
+120
-75
@@ -43,6 +43,8 @@ export const App: React.FC = () => {
|
||||
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[]>([]);
|
||||
@@ -121,19 +123,11 @@ export const App: React.FC = () => {
|
||||
search: filter.search,
|
||||
limit: 100,
|
||||
});
|
||||
setEntries((prev) => {
|
||||
if (filter.view === 'inbox' && selectedEntryId) {
|
||||
const currentActive = prev.find((e) => e.id === selectedEntryId);
|
||||
if (currentActive && !res.entries.some((e) => e.id === selectedEntryId)) {
|
||||
return [currentActive, ...res.entries];
|
||||
}
|
||||
}
|
||||
return res.entries;
|
||||
});
|
||||
setEntries(res.entries);
|
||||
} catch (err) {
|
||||
console.error('Failed to load entries:', err);
|
||||
}
|
||||
}, [currentUser, filter, selectedEntryId]);
|
||||
}, [currentUser, filter]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
@@ -143,27 +137,41 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, [currentUser, loadSidebarData, loadEntries]);
|
||||
|
||||
// Sync focused index with persisted selectedEntryId
|
||||
// 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 idx = entries.findIndex((e) => e.id === selectedEntryId);
|
||||
if (idx >= 0) {
|
||||
setFocusedIndex(idx);
|
||||
const found = entries.find((e) => e.id === selectedEntryId);
|
||||
if (found) {
|
||||
setActiveArticle(found);
|
||||
setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFocusedIndex(0);
|
||||
if (entries[0]) {
|
||||
setSelectedEntryId(entries[0].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||
}
|
||||
setActiveArticle(entries[0]);
|
||||
setSelectedEntryId(entries[0].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||
}
|
||||
}, [entries, selectedEntryId]);
|
||||
}, [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);
|
||||
}
|
||||
@@ -211,6 +219,9 @@ export const App: React.FC = () => {
|
||||
item.id === entry.id ? { ...item, is_read: newStatus } : item
|
||||
)
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev));
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateRead(entry.id, newStatus);
|
||||
@@ -223,6 +234,9 @@ export const App: React.FC = () => {
|
||||
item.id === entry.id ? { ...item, is_read: !newStatus } : item
|
||||
)
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -237,6 +251,9 @@ export const App: React.FC = () => {
|
||||
item.id === entry.id ? { ...item, is_starred: newStatus } : item
|
||||
)
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev));
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateStar(entry.id, newStatus);
|
||||
@@ -248,6 +265,9 @@ export const App: React.FC = () => {
|
||||
item.id === entry.id ? { ...item, is_starred: !newStatus } : item
|
||||
)
|
||||
);
|
||||
if (activeArticle && activeArticle.id === entry.id) {
|
||||
setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,6 +276,9 @@ export const App: React.FC = () => {
|
||||
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));
|
||||
}
|
||||
loadSidebarData();
|
||||
} catch (err) {
|
||||
console.error('Failed to mark all read:', err);
|
||||
@@ -309,63 +332,11 @@ export const App: React.FC = () => {
|
||||
case 'j':
|
||||
case 'J': // Next item
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
||||
if (entries[next]) {
|
||||
setSelectedEntryId(entries[next].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'k':
|
||||
case 'K': // Previous item
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0);
|
||||
if (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
|
||||
if (entries[focusedIndex]?.url) {
|
||||
e.preventDefault();
|
||||
window.open(entries[focusedIndex].url, '_blank');
|
||||
}
|
||||
break;
|
||||
|
||||
case 's':
|
||||
case 'S': // Toggle Star
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleToggleStar(entries[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
case 'M': // Toggle Read / Unread
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleToggleRead(entries[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
case 'E': // Archive / Mark read and advance
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
if (!entries[focusedIndex].is_read) {
|
||||
handleToggleRead(entries[focusedIndex]);
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
||||
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);
|
||||
}
|
||||
@@ -374,6 +345,79 @@ export const App: React.FC = () => {
|
||||
}
|
||||
break;
|
||||
|
||||
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) {
|
||||
@@ -421,7 +465,7 @@ export const App: React.FC = () => {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [entries, focusedIndex]);
|
||||
}, [entries, focusedIndex, activeArticle]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
@@ -467,6 +511,7 @@ export const App: React.FC = () => {
|
||||
stats={stats}
|
||||
entries={entries}
|
||||
selectedIndex={focusedIndex}
|
||||
activeArticle={activeArticle}
|
||||
onSelectEntry={handleSelectEntry}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
|
||||
Reference in New Issue
Block a user