diff --git a/src/components/layout/sources-panel.tsx b/src/components/layout/sources-panel.tsx index 2e7fbf7..fae2915 100644 --- a/src/components/layout/sources-panel.tsx +++ b/src/components/layout/sources-panel.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { ExternalLink, Trash2, Loader2, X, Video, GitFork, Rss, AtSign } from "lucide-react" +import { ExternalLink, Trash2, Loader2, X, Video, GitFork, Rss, AtSign, Pencil } from "lucide-react" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" @@ -54,12 +54,27 @@ function SourceRow({ source, canEdit, onDelete, + onRefresh, }: { source: Source canEdit: boolean onDelete: (id: string) => void + onRefresh: () => void }) { const [deleting, setDeleting] = useState(false) + const [editing, setEditing] = useState(false) + const [editCategory, setEditCategory] = useState(source.category ?? "") + const [editWeight, setEditWeight] = useState( + source.weight != null ? String(source.weight) : "" + ) + const [saving, setSaving] = useState(false) + + // Sync edit fields when editing opens + const handleOpenEdit = useCallback(() => { + setEditCategory(source.category ?? "") + setEditWeight(source.weight != null ? String(source.weight) : "") + setEditing(true) + }, [source.category, source.weight]) const handleDelete = useCallback(async () => { if (!canEdit) return @@ -74,6 +89,24 @@ function SourceRow({ } }, [canEdit, source.ref_id, onDelete]) + const handleSave = useCallback(async () => { + if (!canEdit) return + setSaving(true) + try { + const weightVal = editWeight !== "" ? parseFloat(editWeight) : null + await api.put(`/radar/${source.ref_id}`, { + category: editCategory || null, + weight: weightVal, + }) + setEditing(false) + onRefresh() + } catch { + console.warn("Failed to save source metadata") + } finally { + setSaving(false) + } + }, [canEdit, source.ref_id, editCategory, editWeight, onRefresh]) + const displayName = extractNameFromSource(source.source, source.source_type as never) const typeLabel = SOURCE_TYPE_LABELS[source.source_type] ?? source.source_type @@ -91,49 +124,101 @@ function SourceRow({ const isLink = linkTypes.includes(source.source_type) return ( -
- -
- {isLink ? ( - +
+ +
+ {isLink ? ( + + {displayName} + + ) : ( + + {displayName} + + )} + {typeLabel} + {source.category && ( + + {source.category} + + )} + {source.topics && source.topics.length > 0 && ( +
+ {source.topics.map((t) => ( + + {t} + + ))} +
+ )} +
+ {canEdit && ( + )} - {typeLabel} - {source.topics && source.topics.length > 0 && ( -
- {source.topics.map((t) => ( - - {t} - - ))} -
+ {canEdit && ( + )}
- {canEdit && ( - + + {editing && ( +
+ setEditCategory(e.target.value)} + placeholder="Category" + className="w-full rounded border border-border/50 bg-muted/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + setEditWeight(e.target.value)} + placeholder="Weight (0–1)" + className="w-full rounded border border-border/50 bg-muted/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+ + +
+
)}
) @@ -143,28 +228,35 @@ export function SourcesPanel({ onClose }: { onClose: () => void }) { const { sources, loading, setSources, setLoading, removeSource } = useSourcesStore() const isAdmin = useUserStore((s) => s.isAdmin) + const [selectedCategory, setSelectedCategory] = useState(null) - useEffect(() => { - const fetchSources = async () => { - setLoading(true) - try { - if (isMocksEnabled()) { - setSources(MOCK_SOURCES) - } else { - const res = await api.get<{ data: Source[] }>( - "/radar?skip=0&limit=500" - ) - setSources(res.data ?? []) - } - } catch { - setSources([]) - } finally { - setLoading(false) + const fetchSources = useCallback(async () => { + setLoading(true) + try { + if (isMocksEnabled()) { + setSources(MOCK_SOURCES) + } else { + const res = await api.get<{ data: Source[] }>( + "/radar?skip=0&limit=500" + ) + setSources(res.data ?? []) } + } catch { + setSources([]) + } finally { + setLoading(false) } - fetchSources() }, [setSources, setLoading]) + useEffect(() => { + fetchSources() + }, [fetchSources]) + + const categories = [...new Set(sources.map((s) => s.category).filter(Boolean))] as string[] + const visibleSources = selectedCategory + ? sources.filter((s) => s.category === selectedCategory) + : sources + return (
@@ -211,20 +303,40 @@ export function SourcesPanel({ onClose }: { onClose: () => void }) { )}
) : ( -
- {sources.map((source, i) => ( -
- - {i < sources.length - 1 && ( - - )} + <> + {categories.length > 0 && ( +
+ {(["All", ...categories] as string[]).map((cat) => ( + + ))}
- ))} -
+ )} +
+ {visibleSources.map((source, i) => ( +
+ + {i < visibleSources.length - 1 && ( + + )} +
+ ))} +
+ )}
diff --git a/src/components/modals/add-content-modal.tsx b/src/components/modals/add-content-modal.tsx index 9472336..111ec32 100644 --- a/src/components/modals/add-content-modal.tsx +++ b/src/components/modals/add-content-modal.tsx @@ -56,6 +56,8 @@ export function AddContentModal() { const [price, setPrice] = useState(null) const [topics, setTopics] = useState([]) const [topicDraft, setTopicDraft] = useState("") + const [category, setCategory] = useState("") + const [weight, setWeight] = useState(null) const [cacheStatus, setCacheStatus] = useState(null) const [cachedRefId, setCachedRefId] = useState(null) const [previewState, setPreviewState] = useState(null) @@ -189,6 +191,8 @@ export function AddContentModal() { if (sourceType === SOURCE_TYPES.TWITTER_HANDLE && topics.length) { radarBody.topics = topics } + if (category) radarBody.category = category + if (weight !== null) radarBody.weight = weight await api.post("/radar", radarBody, headers) return } @@ -205,7 +209,7 @@ export function AddContentModal() { await api.post("/v2/content", body, headers) }, - [pubKey, routeHint, topics, cacheStatus, cachedRefId] + [pubKey, routeHint, topics, category, weight, cacheStatus, cachedRefId] ) const handleSubmit = useCallback(async () => { @@ -227,6 +231,8 @@ export function AddContentModal() { setPrice(null) setTopics([]) setTopicDraft("") + setCategory("") + setWeight(null) setCacheStatus(null) setCachedRefId(null) setPreviewState(null) @@ -256,6 +262,8 @@ export function AddContentModal() { setDetectedType(null) setSuccess(false) setPrice(null) + setCategory("") + setWeight(null) setCacheStatus(null) setCachedRefId(null) setPreviewState(null) @@ -297,6 +305,8 @@ export function AddContentModal() { setPrice(null) setTopics([]) setTopicDraft("") + setCategory("") + setWeight(null) setCacheStatus(null) setCachedRefId(null) setPreviewState(null) @@ -451,6 +461,38 @@ export function AddContentModal() {
)} + {/* Admin-only category & weight inputs for subscription sources */} + {isAdmin && detectedType && isSubscriptionSource(detectedType) && ( +
+
+ + setCategory(e.target.value)} + placeholder="e.g. AI, crypto, finance" + className="mt-1 w-full rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+
+ + setWeight(e.target.value ? parseFloat(e.target.value) : null)} + placeholder="0.0 – 1.0" + className="mt-1 w-full rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+
+ )} + {/* Cost & Budget — hidden while preview probe is in-flight or content is owned */} {detectedType && price !== null && price > 0 && !hidePaymentUI && ( <> diff --git a/src/lib/__tests__/add-content-modal.test.tsx b/src/lib/__tests__/add-content-modal.test.tsx index 0614c06..c6e8dda 100644 --- a/src/lib/__tests__/add-content-modal.test.tsx +++ b/src/lib/__tests__/add-content-modal.test.tsx @@ -18,6 +18,7 @@ vi.mock("@/stores/modal-store", () => ({ // --- User store mock --- const mockSetBudget = vi.fn() const mockRefreshBalance = vi.fn().mockResolvedValue(undefined) +let mockIsAdmin = false vi.mock("@/stores/user-store", () => ({ useUserStore: (sel?: (s: unknown) => unknown) => { @@ -26,7 +27,7 @@ vi.mock("@/stores/user-store", () => ({ setBudget: mockSetBudget, pubKey: "testpubkey", routeHint: "", - isAdmin: false, + isAdmin: mockIsAdmin, refreshBalance: mockRefreshBalance, } return sel ? sel(state) : state @@ -392,3 +393,68 @@ describe("AddContentModal — bumpMyContentRefresh on submission", () => { expect(mockBumpMyContentRefresh).not.toHaveBeenCalled() }) }) + + +describe("AddContentModal — admin category/weight fields", () => { + beforeEach(() => { + vi.clearAllMocks() + mockActiveModal = "addContent" + mockIsAdmin = false + mockGetL402.mockResolvedValue(null) + mockPayL402.mockResolvedValue(undefined) + mockGetPrice.mockResolvedValue(0) + mockApiPost.mockResolvedValue({}) + mockRefreshBalance.mockResolvedValue(undefined) + mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null }) + }) + + it("renders category and weight inputs for admins with subscription sources", async () => { + mockIsAdmin = true + mockDetectSourceType.mockResolvedValue("youtube_channel") + mockIsSubscriptionSource.mockReturnValue(true) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://youtube.com/@testchannel") + + await waitFor(() => { + expect(screen.getByPlaceholderText(/e\.g\. AI, crypto, finance/i)).toBeInTheDocument() + expect(screen.getByPlaceholderText(/0\.0 – 1\.0/i)).toBeInTheDocument() + }) + }) + + it("does not render category/weight inputs for non-admins", async () => { + mockIsAdmin = false + mockDetectSourceType.mockResolvedValue("youtube_channel") + mockIsSubscriptionSource.mockReturnValue(true) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://youtube.com/@testchannel") + + await waitFor(() => { + expect(mockDetectSourceType).toHaveBeenCalled() + }) + + expect(screen.queryByPlaceholderText(/e\.g\. AI, crypto, finance/i)).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText(/0\.0 – 1\.0/i)).not.toBeInTheDocument() + }) + + it("does not render category/weight inputs for one-off sources even for admins", async () => { + mockIsAdmin = true + mockDetectSourceType.mockResolvedValue("youtube_video") + mockIsSubscriptionSource.mockReturnValue(false) + mockCheckNodeExists.mockResolvedValue({ exists: false, ref_id: null, status: null }) + + render() + const input = screen.getByPlaceholderText(/Paste URL/) + await userEvent.type(input, "https://youtube.com/watch?v=abc") + + await waitFor(() => { + expect(mockDetectSourceType).toHaveBeenCalled() + }) + + expect(screen.queryByPlaceholderText(/e\.g\. AI, crypto, finance/i)).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText(/0\.0 – 1\.0/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/lib/__tests__/sources-panel.test.tsx b/src/lib/__tests__/sources-panel.test.tsx index cb0a24b..f4bfdab 100644 --- a/src/lib/__tests__/sources-panel.test.tsx +++ b/src/lib/__tests__/sources-panel.test.tsx @@ -164,3 +164,98 @@ describe("SourcesPanel — SourceRow type label", () => { } ) }) + +describe("SourceRow — category badge", () => { + beforeEach(() => { + mockIsAdmin = false + mockLoading = false + }) + + it("renders category badge when source.category is set", () => { + mockSources = [{ ref_id: "r1", source: "jack", source_type: "twitter_handle", category: "crypto" }] + render() + // category text appears both in the source row badge and the filter chip + expect(screen.getAllByText("crypto").length).toBeGreaterThanOrEqual(1) + }) + + it("does not render a category badge when category is absent", () => { + mockSources = [{ ref_id: "r2", source: "staborobot", source_type: "twitter_handle" }] + render() + expect(screen.queryByText("crypto")).not.toBeInTheDocument() + }) +}) + +describe("SourceRow — pencil icon (admin only)", () => { + beforeEach(() => { + mockLoading = false + }) + + it("does not render pencil button when canEdit is false (non-admin)", () => { + mockIsAdmin = false + mockSources = [{ ref_id: "r3", source: "jack", source_type: "twitter_handle" }] + render() + expect(screen.queryByLabelText("Edit source metadata")).not.toBeInTheDocument() + }) + + it("renders pencil button for admins", () => { + mockIsAdmin = true + mockSources = [{ ref_id: "r4", source: "jack", source_type: "twitter_handle" }] + render() + expect(screen.getByLabelText("Edit source metadata")).toBeInTheDocument() + }) +}) + +describe("SourcesPanel — filter chips", () => { + beforeEach(() => { + mockIsAdmin = false + mockLoading = false + }) + + it("hides filter chips when no sources have a category", () => { + mockSources = [ + { ref_id: "r5", source: "a", source_type: "rss" }, + { ref_id: "r6", source: "b", source_type: "rss" }, + ] + render() + expect(screen.queryByRole("button", { name: "All" })).not.toBeInTheDocument() + }) + + it("renders All chip and distinct category chips when categories exist", () => { + mockSources = [ + { ref_id: "r7", source: "a", source_type: "rss", category: "AI" }, + { ref_id: "r8", source: "b", source_type: "rss", category: "crypto" }, + { ref_id: "r9", source: "c", source_type: "rss", category: "AI" }, + ] + render() + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "AI" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "crypto" })).toBeInTheDocument() + // AI appears only once as a chip (duplicate categories de-duped) + expect(screen.getAllByRole("button", { name: "AI" }).length).toBe(1) + }) + + it("filters source rows when a category chip is clicked", async () => { + const { default: userEvent } = await import("@testing-library/user-event") + mockSources = [ + { ref_id: "r10", source: "aisite.com/feed", source_type: "rss", category: "AI" }, + { ref_id: "r11", source: "cryptonews.com/feed", source_type: "rss", category: "crypto" }, + ] + render() + await userEvent.click(screen.getByRole("button", { name: "AI" })) + expect(screen.getByText("aisite.com/feed")).toBeInTheDocument() + expect(screen.queryByText("cryptonews.com/feed")).not.toBeInTheDocument() + }) + + it("resets filter when All chip is clicked", async () => { + const { default: userEvent } = await import("@testing-library/user-event") + mockSources = [ + { ref_id: "r12", source: "aisite.com/feed", source_type: "rss", category: "AI" }, + { ref_id: "r13", source: "cryptonews.com/feed", source_type: "rss", category: "crypto" }, + ] + render() + await userEvent.click(screen.getByRole("button", { name: "AI" })) + await userEvent.click(screen.getByRole("button", { name: "All" })) + expect(screen.getByText("aisite.com/feed")).toBeInTheDocument() + expect(screen.getByText("cryptonews.com/feed")).toBeInTheDocument() + }) +}) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 73aa90a..28a6c28 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -259,7 +259,7 @@ export async function getStats(signal?: AbortSignal): Promise { // Add content via v2/content export async function addContent( - data: { source: string; source_type: string; topics?: string[] }, + data: { source: string; source_type: string; topics?: string[]; category?: string; weight?: number }, signal?: AbortSignal ) { return api.post("/radar", data, undefined, signal) diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index af222d4..5233a9e 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -649,11 +649,11 @@ export const MOCK_EDGES: GraphEdge[] = [ ] export const MOCK_SOURCES = [ - { ref_id: "s1", source: "jack", source_type: "twitter_handle" }, + { ref_id: "s1", source: "jack", source_type: "twitter_handle", category: "crypto", weight: 0.9 }, { ref_id: "s2", source: "staborobot", source_type: "twitter_handle" }, - { ref_id: "s3", source: "https://www.youtube.com/@bitcoinmagazine", source_type: "youtube_channel" }, - { ref_id: "s4", source: "https://bitcoinist.com/feed/", source_type: "rss" }, - { ref_id: "s5", source: "https://github.com/nicksparks/sphinx-nav-fiber", source_type: "github_repository" }, + { ref_id: "s3", source: "https://www.youtube.com/@bitcoinmagazine", source_type: "youtube_channel", category: "crypto", weight: 0.8 }, + { ref_id: "s4", source: "https://bitcoinist.com/feed/", source_type: "rss", category: "AI", weight: 0.6 }, + { ref_id: "s5", source: "https://github.com/nicksparks/sphinx-nav-fiber", source_type: "github_repository", category: "AI", weight: 0.5 }, ] export const MOCK_CONTENT = { diff --git a/src/stores/sources-store.ts b/src/stores/sources-store.ts index 61addc0..f6c1261 100644 --- a/src/stores/sources-store.ts +++ b/src/stores/sources-store.ts @@ -7,6 +7,8 @@ export interface Source { source: string source_type: string topics?: string[] + category?: string + weight?: number } interface SourcesState {