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}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user