refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission. - Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication. - Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse). - Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization. - Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5). - Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations. - Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets. - Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels. - Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar. - Add architectural documentation and worklog in docs/009_worklog.md.
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="RSSPipe - RSS Driven Information Gateway" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
+50
-1
@@ -306,9 +306,57 @@ export const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Connect Real-Time SSE Stream for live state sync across tabs and async background jobs
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const disconnect = api.connectEventStream((eventType, data) => {
|
||||
if (eventType === 'job_completed' || eventType === 'post_updated' || eventType === 'variant_updated') {
|
||||
const updatedEntry = data?.entry || data;
|
||||
if (updatedEntry && updatedEntry.id) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === updatedEntry.id
|
||||
? {
|
||||
...e,
|
||||
...updatedEntry,
|
||||
variants: { ...(e.variants || {}), ...(updatedEntry.variants || {}) },
|
||||
}
|
||||
: e
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (eventType === 'post_created') {
|
||||
loadSidebarData();
|
||||
} else if (eventType === 'preferences_updated') {
|
||||
if (data && typeof data === 'object') {
|
||||
if (data.auto_read !== undefined) {
|
||||
localStorage.setItem('rsspipe_auto_read_scroll', data.auto_read ? 'true' : 'false');
|
||||
}
|
||||
if (data.view_preference) {
|
||||
setViewPreference(data.view_preference);
|
||||
localStorage.setItem('rsspipe_view_preference', data.view_preference);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => disconnect();
|
||||
}, [currentUser, loadSidebarData]);
|
||||
|
||||
// Update Single Entry Action
|
||||
const handleUpdateEntry = (updated: FeedEntry) => {
|
||||
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === updated.id
|
||||
? {
|
||||
...e,
|
||||
...updated,
|
||||
variants: { ...(e.variants || {}), ...(updated.variants || {}) },
|
||||
}
|
||||
: e
|
||||
)
|
||||
);
|
||||
loadSidebarData();
|
||||
};
|
||||
|
||||
@@ -500,6 +548,7 @@ export const App: React.FC = () => {
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onUpdateEntry={handleUpdateEntry}
|
||||
stats={stats}
|
||||
totalEntries={totalEntries}
|
||||
onOpenSettings={() => setViewMode('settings')}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
onPlayAudio={handlePlayAudio}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface DesktopArticleListProps {
|
||||
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
|
||||
feeds?: Feed[];
|
||||
categories?: Category[];
|
||||
stats?: import('../../types').Stats | null;
|
||||
totalEntries?: number;
|
||||
onFilterChange?: (newFilter: Partial<FilterState>) => void;
|
||||
}
|
||||
|
||||
@@ -52,6 +54,8 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
|
||||
onToggleStar,
|
||||
feeds = [],
|
||||
categories = [],
|
||||
stats,
|
||||
totalEntries = 0,
|
||||
onFilterChange,
|
||||
}) => {
|
||||
const getFilterTitle = () => {
|
||||
@@ -70,7 +74,35 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
|
||||
return 'Inbox';
|
||||
};
|
||||
|
||||
const unreadCount = entries.filter((e) => !e.is_read).length;
|
||||
// Calculate accurate unread and total counts from feeds/categories/stats
|
||||
const getFilterCounts = () => {
|
||||
if (filter.feedId) {
|
||||
const activeFeed = feeds.find((f) => f.id === filter.feedId);
|
||||
return {
|
||||
unread: activeFeed?.unread_count || 0,
|
||||
total: activeFeed?.total_count || totalEntries || 0,
|
||||
};
|
||||
}
|
||||
if (filter.categoryId) {
|
||||
const catFeeds = feeds.filter((f) => f.category_id === filter.categoryId);
|
||||
const unread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
|
||||
const total = catFeeds.reduce((acc, f) => acc + (f.total_count || 0), 0);
|
||||
return { unread, total: total || totalEntries || 0 };
|
||||
}
|
||||
if (filter.view === 'starred') {
|
||||
return { unread: 0, total: stats?.total_starred || totalEntries || 0 };
|
||||
}
|
||||
if (filter.view === 'all') {
|
||||
return { unread: stats?.total_unread || 0, total: totalEntries || 0 };
|
||||
}
|
||||
if (filter.view === 'shared') {
|
||||
return { unread: 0, total: stats?.total_shared || totalEntries || 0 };
|
||||
}
|
||||
// 'inbox'
|
||||
return { unread: stats?.total_unread || 0, total: stats?.total_unread || totalEntries || 0 };
|
||||
};
|
||||
|
||||
const { unread: trueUnreadCount, total: trueTotalCount } = getFilterCounts();
|
||||
|
||||
return (
|
||||
<div className="desktop-list-pane" style={{ width: `${width}px` }}>
|
||||
@@ -80,7 +112,21 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
|
||||
<span className="list-title-text" title={getFilterTitle()}>
|
||||
{getFilterTitle()}
|
||||
</span>
|
||||
{unreadCount > 0 && <span className="badge badge-unread">{unreadCount}</span>}
|
||||
{filter.view === 'inbox' ? (
|
||||
trueUnreadCount > 0 && <span className="badge badge-unread">{trueUnreadCount}</span>
|
||||
) : filter.view === 'all' ? (
|
||||
trueTotalCount > 0 && (
|
||||
<span className="badge badge-unread" style={{ background: 'rgba(148, 163, 184, 0.15)', color: 'var(--text-secondary)' }}>
|
||||
{trueTotalCount}
|
||||
</span>
|
||||
)
|
||||
) : filter.view === 'starred' ? (
|
||||
trueTotalCount > 0 && (
|
||||
<span className="badge badge-unread" style={{ background: 'rgba(234, 179, 8, 0.15)', color: 'var(--accent-star)' }}>
|
||||
{trueTotalCount}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
@@ -104,7 +150,7 @@ export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unreadCount > 0 && (
|
||||
{trueUnreadCount > 0 && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onMarkAllRead}
|
||||
|
||||
@@ -226,7 +226,11 @@ export const DesktopReader: React.FC<DesktopReaderProps> = ({
|
||||
setIsSharing(true);
|
||||
try {
|
||||
const res = await api.toggleShare(entry.id);
|
||||
onUpdateEntry?.(res.entry);
|
||||
const updatedEntry = {
|
||||
...res.entry,
|
||||
share_uuid: res.share_uuid ?? null,
|
||||
};
|
||||
onUpdateEntry?.(updatedEntry);
|
||||
if (res.shared && res.share_uuid) {
|
||||
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ThreePaneViewProps {
|
||||
onMarkAllRead: () => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
stats: Stats | null;
|
||||
totalEntries?: number;
|
||||
onOpenSettings: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||
@@ -38,6 +39,7 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
onMarkAllRead,
|
||||
onUpdateEntry,
|
||||
stats,
|
||||
totalEntries = 0,
|
||||
onOpenSettings,
|
||||
onOpenAddFeed,
|
||||
onPlayAudio,
|
||||
@@ -115,6 +117,8 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
onToggleStar={onToggleStar}
|
||||
feeds={feeds}
|
||||
categories={categories}
|
||||
stats={stats}
|
||||
totalEntries={totalEntries}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
|
||||
@@ -201,7 +201,11 @@ export const MobileArticleSheet: React.FC<MobileArticleSheetProps> = ({
|
||||
try {
|
||||
const res = await api.toggleShare(entry.id);
|
||||
if (res && res.entry) {
|
||||
onUpdateEntry?.(res.entry);
|
||||
const updatedEntry = {
|
||||
...res.entry,
|
||||
share_uuid: res.share_uuid ?? null,
|
||||
};
|
||||
onUpdateEntry?.(updatedEntry);
|
||||
}
|
||||
if (res && res.shared && res.share_uuid) {
|
||||
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
|
||||
|
||||
@@ -306,8 +306,82 @@ export const api = {
|
||||
return request<void>(`/search/history${qParam}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// Patch Entry (Atomic transition mutation)
|
||||
async patchEntry(
|
||||
id: string,
|
||||
patch: {
|
||||
is_read?: boolean;
|
||||
is_starred?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
variants?: Record<string, any>;
|
||||
}
|
||||
): Promise<{ entry: FeedEntry; diff: any }> {
|
||||
return request<{ entry: FeedEntry; diff: any }>(`/entries/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
},
|
||||
|
||||
// Background Task Queue & Jobs
|
||||
async listJobs(params?: { type?: string; status?: string; limit?: number; offset?: number }): Promise<{ jobs: any[]; limit: number; offset: number }> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.type) query.set('type', params.type);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.limit) query.set('limit', params.limit.toString());
|
||||
if (params?.offset) query.set('offset', params.offset.toString());
|
||||
return request(`/jobs?${query.toString()}`);
|
||||
},
|
||||
|
||||
async getJob(id: string): Promise<any> {
|
||||
return request<any>(`/jobs/${id}`);
|
||||
},
|
||||
|
||||
async cancelJob(id: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(`/jobs/${id}/cancel`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async retryJob(id: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(`/jobs/${id}/retry`, { method: 'POST' });
|
||||
},
|
||||
|
||||
// Real-Time Server-Sent Events Stream
|
||||
connectEventStream(onEvent: (eventType: string, data: any) => void): () => void {
|
||||
const eventSource = new EventSource(`${API_BASE}/events/stream`);
|
||||
|
||||
const eventNames = [
|
||||
'connected',
|
||||
'ping',
|
||||
'preferences_updated',
|
||||
'job_progress',
|
||||
'job_completed',
|
||||
'job_failed',
|
||||
'post_created',
|
||||
'post_updated',
|
||||
];
|
||||
|
||||
eventNames.forEach((name) => {
|
||||
eventSource.addEventListener(name, (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
onEvent(name, data);
|
||||
} catch {
|
||||
onEvent(name, e.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.warn('SSE connection error:', err);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -64,8 +64,9 @@
|
||||
|
||||
/* Layout Dimensions */
|
||||
--header-height: 50px;
|
||||
--bottom-nav-height: 72px;
|
||||
--docked-audio-height: 56px;
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--bottom-nav-height: calc(50px + env(safe-area-inset-bottom, 0px));
|
||||
--docked-audio-height: 54px;
|
||||
--sidebar-min-width: 200px;
|
||||
--sidebar-default-width: 250px;
|
||||
--list-min-width: 300px;
|
||||
|
||||
@@ -375,11 +375,12 @@
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 6px 0.5rem calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 4px 0.5rem calc(var(--safe-area-bottom) + 2px);
|
||||
z-index: 50;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
@@ -467,7 +468,7 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 1.15rem 1rem calc(65px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 1.15rem 1rem calc(56px + var(--safe-area-bottom));
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
@@ -479,8 +480,8 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(52px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 0 0.85rem env(safe-area-inset-bottom, 0px);
|
||||
height: calc(48px + var(--safe-area-bottom));
|
||||
padding: 4px 0.85rem calc(var(--safe-area-bottom) + 2px);
|
||||
background: var(--bg-docked);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
@@ -490,6 +491,7 @@
|
||||
justify-content: space-between;
|
||||
z-index: 110;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mobile-sheet-bottom-left {
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ export interface FeedEntry {
|
||||
published_at: string;
|
||||
is_read: boolean;
|
||||
is_starred: boolean;
|
||||
share_uuid?: string;
|
||||
share_uuid?: string | null;
|
||||
variants?: Record<string, EntryVariant>;
|
||||
metadata?: Record<string, any>;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user