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}
+120
View File
@@ -20,6 +20,8 @@ import {
Clock,
Timer,
Eye,
Lock,
Key,
} from 'lucide-react';
interface SettingsViewProps {
@@ -71,6 +73,43 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
const [opmlLoading, setOpmlLoading] = useState(false);
const [opmlResult, setOpmlResult] = useState<string | null>(null);
// Change Password state
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError(null);
setPasswordSuccess(null);
if (newPassword.length < 6) {
setPasswordError('New password must be at least 6 characters long');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('New passwords do not match');
return;
}
setPasswordLoading(true);
try {
await api.changePassword(oldPassword, newPassword);
setPasswordSuccess('Password updated successfully! Please use your new password next time you log in.');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
setPasswordError(err.message || 'Failed to update password');
} finally {
setPasswordLoading(false);
}
};
// Filter feeds
const filteredFeeds = feeds.filter(
(f) =>
@@ -633,6 +672,87 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
</div>
</div>
<div className="settings-card">
<h3 className="settings-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Lock size={15} style={{ color: 'var(--accent)' }} />
<span>Change Password</span>
</h3>
<p className="settings-card-subtitle">
Update your single-user administrator password. Minimum 6 characters required.
</p>
{passwordSuccess && (
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: 'var(--success)', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<CheckCircle2 size={16} />
<span>{passwordSuccess}</span>
</div>
)}
{passwordError && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: 'var(--danger)', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<AlertCircle size={16} />
<span>{passwordError}</span>
</div>
)}
<form onSubmit={handleChangePassword} style={{ maxWidth: 420, marginTop: '0.75rem' }}>
<div className="form-group">
<label className="form-label">Current Password</label>
<input
type="password"
className="form-input"
placeholder="Enter current password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">New Password</label>
<input
type="password"
className="form-input"
placeholder="Enter new password (min. 6 characters)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="form-group">
<label className="form-label">Confirm New Password</label>
<input
type="password"
className="form-input"
placeholder="Re-enter new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
className="btn-primary"
disabled={passwordLoading}
style={{ marginTop: '0.5rem' }}
>
{passwordLoading ? (
<>
<Loader2 size={14} className="animate-spin" />
<span>Updating Password...</span>
</>
) : (
<>
<Key size={14} />
<span>Update Password</span>
</>
)}
</button>
</form>
</div>
<div className="settings-card">
<h3 className="settings-card-title">Storage & Engine Stats</h3>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
+3 -1
View File
@@ -20,6 +20,7 @@ interface ThreePaneViewProps {
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;
@@ -35,6 +36,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
stats,
entries,
selectedIndex,
activeArticle,
onSelectEntry,
onToggleRead,
onToggleStar,
@@ -44,7 +46,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const readerPaneRef = useRef<HTMLDivElement>(null);
const selectedEntry = entries[selectedIndex] || entries[0];
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
// Auto-scroll list to active card
useEffect(() => {
+14
View File
@@ -1130,3 +1130,17 @@ html, body, #root {
font-size: 0.8rem;
margin-bottom: 1.1rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
+7
View File
@@ -64,6 +64,13 @@ export const api = {
return request<User>('/auth/me');
},
async changePassword(old_password: string, new_password: string): Promise<{ message: string }> {
return request<{ message: string }>('/auth/password', {
method: 'PUT',
body: JSON.stringify({ old_password, new_password }),
});
},
// Stats
async getStats(): Promise<Stats> {
return request<Stats>('/stats');