feat: add password management, reading persistence on feed switch, and spin animation

This commit is contained in:
2026-08-24 21:53:54 -07:00
parent d7ed8876dc
commit 5ea176d5ad
12 changed files with 424 additions and 76 deletions
+120 -75
View File
@@ -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}