+ {/* Page header */}
+
+
+
+
+
Feed
+
+ Content ranked by your knowledge graph interests
+
+
+
+
+
+ {/* Stats pills */}
+ {stats && (
+
+ {stats.total_posts} posts
+ |
+ {stats.unseen_posts} unseen
+ |
+ {stats.sources_count} sources
+
+ )}
+
+ {/* Toggle seen */}
+
+
+ {/* Add source */}
+
+
+ {/* Refresh β scoped to active tab's platform */}
+
+
+
+
+ {/* Tab bar */}
+
+
+
+
+
+ {/* Refresh result banner */}
+ {lastResult && (
+
+ Fetched {lastResult.posts_fetched} posts, {lastResult.posts_new} new ·{' '}
+ {lastResult.interests_count} interests used
+
+ )}
+
+ {/* Error */}
+ {error && (
+
+
+ {(error as Error).message || 'Failed to load feed'}
+
+ )}
+
+ {/* Interest chips */}
+ {interests.length > 0 && (
+
+
+ {interests.map((interest) => (
+ handleInterestClick(interest.name)}
+ />
+ ))}
+
+ )}
+
+ {/* Sources list β scoped to active tab */}
+
+
Sources:
+ {tabSources.map((s) => (
+
+ {s.platform_type === 'youtube' && }
+ {s.name}
+
+
+ ))}
+
+
+
+ {/* Loading state */}
+ {isLoading && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {!isLoading && posts.length === 0 && (
+
setShowAddSource(true)}
+ onRefresh={handleRefresh}
+ isRefreshing={isRefreshing}
+ />
+ )}
+
+ {/* Post list β conditional card type */}
+ {!isLoading && posts.length > 0 && (
+
+ {posts.map((post) =>
+ post.platform_type === 'youtube' ? (
+
+ ) : (
+
+ ),
+ )}
+
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ Page {page} of {totalPages} · {total} posts
+
+
+
+ )}
+
+ {/* Fetching indicator (background refetch) */}
+ {isFetching && !isLoading && (
+
+
+ Updating...
+
+ )}
+
+ {/* Add source modal */}
+ setShowAddSource(false)}
+ onAdd={addSource}
+ isAdding={isAdding}
+ defaultPlatform={platformType as 'mastodon' | 'youtube'}
+ />
+
+ )
+}
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts
index 725ad882..571df424 100644
--- a/ushadow/frontend/src/services/api.ts
+++ b/ushadow/frontend/src/services/api.ts
@@ -71,7 +71,8 @@ api.interceptors.request.use((config) => {
// Prefer Keycloak token if both are present
const token = kcToken || legacyToken
- if (token) {
+ // Only add Authorization header if we have a valid token (not null, not empty)
+ if (token && token !== 'null' && token !== 'undefined') {
config.headers.Authorization = `Bearer ${token}`
}
return config
diff --git a/ushadow/frontend/src/services/feedApi.ts b/ushadow/frontend/src/services/feedApi.ts
new file mode 100644
index 00000000..5dd43abf
--- /dev/null
+++ b/ushadow/frontend/src/services/feedApi.ts
@@ -0,0 +1,130 @@
+/**
+ * Feed API Client
+ *
+ * HTTP functions for the personalized multi-platform feed feature.
+ * Uses the shared `api` axios instance (includes JWT auth automatically).
+ */
+
+import { api } from './api'
+
+export interface FeedPost {
+ post_id: string
+ user_id: string
+ source_id: string
+ external_id: string
+ platform_type: string // 'mastodon' | 'youtube'
+ author_handle: string
+ author_display_name: string
+ author_avatar: string | null
+ content: string
+ url: string
+ published_at: string
+ hashtags: string[]
+ language: string | null
+ // Mastodon engagement (optional β null for non-mastodon)
+ boosts_count: number | null
+ favourites_count: number | null
+ replies_count: number | null
+ // YouTube-specific (optional β null for non-youtube)
+ thumbnail_url?: string | null
+ video_id?: string | null
+ channel_title?: string | null
+ view_count?: number | null
+ like_count?: number | null
+ duration?: string | null
+ // Scoring & interaction
+ relevance_score: number
+ matched_interests: string[]
+ seen: boolean
+ bookmarked: boolean
+ fetched_at: string
+}
+
+export interface FeedInterest {
+ name: string
+ node_id: string
+ labels: string[]
+ relationship_count: number
+ last_active: string | null
+ hashtags: string[]
+}
+
+export interface FeedSource {
+ source_id: string
+ user_id: string
+ name: string
+ platform_type: string
+ instance_url: string | null
+ api_key: string | null
+ enabled: boolean
+ created_at: string
+}
+
+export interface FeedResponse {
+ posts: FeedPost[]
+ total: number
+ page: number
+ page_size: number
+ total_pages: number
+}
+
+export interface RefreshResult {
+ status: string
+ interests_count: number
+ interests_used?: Array<{ name: string; hashtags: string[]; weight: number }>
+ posts_fetched: number
+ posts_scored?: number
+ posts_new: number
+ message?: string
+}
+
+export interface SourceCreateData {
+ name: string
+ platform_type: string
+ instance_url?: string
+ api_key?: string
+}
+
+export const feedApi = {
+ // Posts
+ getPosts: (params: {
+ page?: number
+ page_size?: number
+ interest?: string
+ show_seen?: boolean
+ platform_type?: string
+ }) => api.get