diff --git a/apps/web/src/functions/github-stars.ts b/apps/web/src/functions/github-stars.ts new file mode 100644 index 0000000000..2722f9b408 --- /dev/null +++ b/apps/web/src/functions/github-stars.ts @@ -0,0 +1,381 @@ +import postgres from "postgres"; + +import { env, requireEnv } from "@/env"; + +function getSql() { + return postgres(requireEnv(env.DATABASE_URL, "DATABASE_URL"), { + prepare: false, + }); +} + +export interface StarLead { + id: number; + github_username: string; + github_id: number | null; + avatar_url: string | null; + profile_url: string | null; + bio: string | null; + event_type: string; + repo_name: string; + name: string | null; + company: string | null; + is_match: boolean | null; + score: number | null; + reasoning: string | null; + researched_at: string | null; + event_at: string; + created_at: string; +} + +export async function listStarLeads(options?: { + limit?: number; + offset?: number; + researchedOnly?: boolean; +}): Promise<{ leads: StarLead[]; total: number }> { + const sql = getSql(); + const limit = options?.limit ?? 50; + const offset = options?.offset ?? 0; + + const countResult = options?.researchedOnly + ? await sql`SELECT COUNT(*) as count FROM public.github_star_leads WHERE researched_at IS NOT NULL` + : await sql`SELECT COUNT(*) as count FROM public.github_star_leads`; + const total = parseInt(String(countResult[0].count), 10); + + const rows = options?.researchedOnly + ? await sql`SELECT * FROM public.github_star_leads WHERE researched_at IS NOT NULL ORDER BY COALESCE(score, -1) DESC, created_at DESC LIMIT ${limit} OFFSET ${offset}` + : await sql`SELECT * FROM public.github_star_leads ORDER BY COALESCE(score, -1) DESC, created_at DESC LIMIT ${limit} OFFSET ${offset}`; + + return { leads: rows as unknown as StarLead[], total }; +} + +interface GitHubUser { + login: string; + id: number; + avatar_url: string; + html_url: string; + type: string; +} + +interface GitHubEvent { + type: string; + actor: { + login: string; + id: number; + avatar_url: string; + url: string; + }; + repo: { + name: string; + }; + created_at: string; +} + +export async function fetchGitHubStargazers(): Promise<{ + added: number; + total: number; +}> { + const sql = getSql(); + let added = 0; + let page = 1; + const perPage = 100; + + while (true) { + const response = await fetch( + `https://api.github.com/repos/fastrepl/hyprnote/stargazers?per_page=${perPage}&page=${page}`, + { + headers: { + Accept: "application/vnd.github.star+json", + "User-Agent": "hyprnote-admin", + }, + }, + ); + + if (!response.ok) break; + + const stargazers: Array<{ starred_at: string; user: GitHubUser }> = + await response.json(); + if (stargazers.length === 0) break; + + for (const s of stargazers) { + if (s.user.type === "Bot") continue; + + const result = await sql` + INSERT INTO public.github_star_leads (github_username, github_id, avatar_url, profile_url, event_type, repo_name, event_at) + VALUES (${s.user.login}, ${s.user.id}, ${s.user.avatar_url}, ${s.user.html_url}, 'star', 'fastrepl/hyprnote', ${s.starred_at}) + ON CONFLICT (github_username) DO UPDATE SET + avatar_url = EXCLUDED.avatar_url, + github_id = EXCLUDED.github_id + RETURNING id`; + + if (result.length > 0) { + added++; + } + } + + if (stargazers.length < perPage) break; + page++; + } + + const countResult = + await sql`SELECT COUNT(*) as count FROM public.github_star_leads`; + + return { added, total: parseInt(String(countResult[0].count), 10) }; +} + +export async function fetchGitHubActivity(): Promise<{ + added: number; + total: number; +}> { + const sql = getSql(); + let added = 0; + + const response = await fetch( + "https://api.github.com/orgs/fastrepl/events?per_page=100", + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "hyprnote-admin", + }, + }, + ); + + if (!response.ok) { + return { added: 0, total: 0 }; + } + + const events: GitHubEvent[] = await response.json(); + + const eventTypeMap: Record = { + WatchEvent: "star", + ForkEvent: "fork", + IssuesEvent: "issue", + PullRequestEvent: "pr", + IssueCommentEvent: "comment", + PushEvent: "push", + CreateEvent: "create", + }; + + for (const event of events) { + const eventType = eventTypeMap[event.type] || event.type; + if (!event.actor.login) continue; + + const userResponse = await fetch( + `https://api.github.com/users/${event.actor.login}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "hyprnote-admin", + }, + }, + ); + + let bio: string | null = null; + if (userResponse.ok) { + const userData = await userResponse.json(); + bio = userData.bio; + } + + const profileUrl = `https://github.com/${event.actor.login}`; + + await sql` + INSERT INTO public.github_star_leads (github_username, github_id, avatar_url, profile_url, bio, event_type, repo_name, event_at) + VALUES (${event.actor.login}, ${event.actor.id}, ${event.actor.avatar_url}, ${profileUrl}, ${bio}, ${eventType}, ${event.repo.name}, ${event.created_at}) + ON CONFLICT (github_username) DO UPDATE SET + avatar_url = EXCLUDED.avatar_url, + bio = COALESCE(EXCLUDED.bio, github_star_leads.bio), + event_type = EXCLUDED.event_type, + event_at = GREATEST(EXCLUDED.event_at, github_star_leads.event_at)`; + added++; + } + + const countResult = + await sql`SELECT COUNT(*) as count FROM public.github_star_leads`; + + return { added, total: parseInt(String(countResult[0].count), 10) }; +} + +const RESEARCH_PROMPT = `You are an assistant to the founders of Hyprnote. + +Hyprnote is a privacy-first AI notepad for meetings — it runs transcription and summarization locally on-device, without bots or cloud recording. Think of it as the "anti-Otter.ai" for professionals who care about privacy. + +I'm sending you data about a GitHub user who interacted with our repository (starred, forked, opened an issue, etc). Your job is to exhaustively research this person using the information provided to determine if they are: + +1. A potential customer (someone who would benefit from Hyprnote) +2. A potential hire (talented developer who could contribute to Hyprnote) +3. A potential community contributor + +Hyprnote's ideal customer profile: +1. Professional who has frequent meetings (sales, consulting, recruiting, healthcare, legal, journalism, engineering management) +2. Privacy-conscious — works with sensitive data +3. Tech-savvy enough to appreciate local AI / on-device processing +4. Uses a Mac (our primary platform) + +Hyprnote's ideal hire profile: +1. Strong Rust and/or TypeScript developer +2. Experience with audio processing, ML/AI, or desktop apps (Tauri/Electron) +3. Open source contributor +4. Passionate about privacy and local-first software + +Return your final response in JSON only with the following schema: +{ + "name": string, + "company": string, + "match": boolean, + "score": number, + "reasoning": string +} + +- The score field is from 0 to 100. +- The company is where they currently work, or "" if unknown. +- For the "reasoning" field, write in Markdown. Include newlines where appropriate. +- If the person works at Hyprnote (fastrepl), there is no match and the score is 0. +- Focus on whether they'd be a good customer, hire, or contributor.`; + +export async function researchLead( + username: string, + openrouterApiKey: string, +): Promise<{ + success: boolean; + lead?: StarLead; + error?: string; +}> { + const sql = getSql(); + + const existing = + await sql`SELECT * FROM public.github_star_leads WHERE github_username = ${username}`; + + if (existing.length === 0) { + return { success: false, error: "User not found in leads table" }; + } + + const lead = existing[0] as unknown as StarLead; + + const profileResponse = await fetch( + `https://api.github.com/users/${username}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "hyprnote-admin", + }, + }, + ); + + let profileData: Record = {}; + if (profileResponse.ok) { + profileData = await profileResponse.json(); + } + + const reposResponse = await fetch( + `https://api.github.com/users/${username}/repos?sort=stars&per_page=10`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "hyprnote-admin", + }, + }, + ); + + let topRepos: Array<{ + name: string; + description: string | null; + language: string | null; + stargazers_count: number; + }> = []; + if (reposResponse.ok) { + topRepos = await reposResponse.json(); + } + + const userInfo = `GitHub Username: ${username} +Name: ${profileData.name || "Unknown"} +Bio: ${profileData.bio || "N/A"} +Company: ${profileData.company || "N/A"} +Location: ${profileData.location || "N/A"} +Blog/Website: ${profileData.blog || "N/A"} +Twitter: ${profileData.twitter_username || "N/A"} +Public Repos: ${profileData.public_repos || 0} +Followers: ${profileData.followers || 0} +Following: ${profileData.following || 0} +Profile URL: https://github.com/${username} +Event Type: ${lead.event_type} on ${lead.repo_name} + +Top Repositories: +${topRepos + .slice(0, 5) + .map( + (r) => + `- ${r.name}: ${r.description || "No description"} (${r.language || "Unknown"}, ${r.stargazers_count} stars)`, + ) + .join("\n")}`; + + const llmResponse = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${openrouterApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/gpt-4o-mini", + messages: [ + { role: "system", content: RESEARCH_PROMPT }, + { + role: "user", + content: `Research this GitHub user:\n\n${userInfo}`, + }, + ], + temperature: 0.3, + response_format: { type: "json_object" }, + }), + }, + ); + + if (!llmResponse.ok) { + const errText = await llmResponse.text(); + return { success: false, error: `OpenRouter API error: ${errText}` }; + } + + const llmData = await llmResponse.json(); + const content = llmData.choices?.[0]?.message?.content; + + if (!content) { + return { success: false, error: "No response from LLM" }; + } + + let parsed: { + name: string; + company: string; + match: boolean; + score: number; + reasoning: string; + }; + try { + parsed = JSON.parse(content); + } catch { + return { + success: false, + error: `Failed to parse LLM response: ${content}`, + }; + } + + const parsedName = parsed.name || ""; + const parsedCompany = parsed.company || ""; + const parsedReasoning = parsed.reasoning || ""; + const parsedBio = profileData.bio ? String(profileData.bio) : null; + + await sql` + UPDATE public.github_star_leads SET + name = ${parsedName}, + company = ${parsedCompany}, + is_match = ${parsed.match}, + score = ${parsed.score}, + reasoning = ${parsedReasoning}, + bio = COALESCE(bio, ${parsedBio}), + researched_at = NOW() + WHERE github_username = ${username}`; + + const updated = + await sql`SELECT * FROM public.github_star_leads WHERE github_username = ${username}`; + + return { success: true, lead: updated[0] as unknown as StarLead }; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7c5c126e4b..297434c920 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as ViewAboutRouteImport } from './routes/_view/about' import { Route as ViewDocsRouteRouteImport } from './routes/_view/docs/route' import { Route as ViewCompanyHandbookRouteRouteImport } from './routes/_view/company-handbook/route' import { Route as ViewAppRouteRouteImport } from './routes/_view/app/route' +import { Route as AdminStarsIndexRouteImport } from './routes/admin/stars/index' import { Route as AdminMediaIndexRouteImport } from './routes/admin/media/index' import { Route as AdminCollectionsIndexRouteImport } from './routes/admin/collections/index' import { Route as ViewTemplatesIndexRouteImport } from './routes/_view/templates/index' @@ -116,6 +117,9 @@ import { Route as ViewAppIntegrationRouteImport } from './routes/_view/app/integ import { Route as ViewAppFileTranscriptionRouteImport } from './routes/_view/app/file-transcription' import { Route as ViewAppCheckoutRouteImport } from './routes/_view/app/checkout' import { Route as ViewAppAccountRouteImport } from './routes/_view/app/account' +import { Route as ApiAdminStarsResearchRouteImport } from './routes/api/admin/stars/research' +import { Route as ApiAdminStarsLeadsRouteImport } from './routes/api/admin/stars/leads' +import { Route as ApiAdminStarsFetchRouteImport } from './routes/api/admin/stars/fetch' import { Route as ApiAdminMediaUploadRouteImport } from './routes/api/admin/media/upload' import { Route as ApiAdminMediaMoveRouteImport } from './routes/api/admin/media/move' import { Route as ApiAdminMediaListRouteImport } from './routes/api/admin/media/list' @@ -309,6 +313,11 @@ const ViewAppRouteRoute = ViewAppRouteRouteImport.update({ path: '/app', getParentRoute: () => ViewRouteRoute, } as any) +const AdminStarsIndexRoute = AdminStarsIndexRouteImport.update({ + id: '/stars/', + path: '/stars/', + getParentRoute: () => AdminRouteRoute, +} as any) const AdminMediaIndexRoute = AdminMediaIndexRouteImport.update({ id: '/media/', path: '/media/', @@ -684,6 +693,21 @@ const ViewAppAccountRoute = ViewAppAccountRouteImport.update({ path: '/account', getParentRoute: () => ViewAppRouteRoute, } as any) +const ApiAdminStarsResearchRoute = ApiAdminStarsResearchRouteImport.update({ + id: '/api/admin/stars/research', + path: '/api/admin/stars/research', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminStarsLeadsRoute = ApiAdminStarsLeadsRouteImport.update({ + id: '/api/admin/stars/leads', + path: '/api/admin/stars/leads', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAdminStarsFetchRoute = ApiAdminStarsFetchRouteImport.update({ + id: '/api/admin/stars/fetch', + path: '/api/admin/stars/fetch', + getParentRoute: () => rootRouteImport, +} as any) const ApiAdminMediaUploadRoute = ApiAdminMediaUploadRouteImport.update({ id: '/api/admin/media/upload', path: '/api/admin/media/upload', @@ -910,6 +934,7 @@ export interface FileRoutesByFullPath { '/templates/': typeof ViewTemplatesIndexRoute '/admin/collections/': typeof AdminCollectionsIndexRoute '/admin/media/': typeof AdminMediaIndexRoute + '/admin/stars/': typeof AdminStarsIndexRoute '/gallery/$type/$slug': typeof ViewGalleryTypeSlugRoute '/integrations/$category/$slug': typeof ViewIntegrationsCategorySlugRoute '/api/admin/blog/upload-image': typeof ApiAdminBlogUploadImageRoute @@ -932,6 +957,9 @@ export interface FileRoutesByFullPath { '/api/admin/media/list': typeof ApiAdminMediaListRoute '/api/admin/media/move': typeof ApiAdminMediaMoveRoute '/api/admin/media/upload': typeof ApiAdminMediaUploadRoute + '/api/admin/stars/fetch': typeof ApiAdminStarsFetchRoute + '/api/admin/stars/leads': typeof ApiAdminStarsLeadsRoute + '/api/admin/stars/research': typeof ApiAdminStarsResearchRoute } export interface FileRoutesByTo { '/auth': typeof AuthRoute @@ -1036,6 +1064,7 @@ export interface FileRoutesByTo { '/templates': typeof ViewTemplatesIndexRoute '/admin/collections': typeof AdminCollectionsIndexRoute '/admin/media': typeof AdminMediaIndexRoute + '/admin/stars': typeof AdminStarsIndexRoute '/gallery/$type/$slug': typeof ViewGalleryTypeSlugRoute '/integrations/$category/$slug': typeof ViewIntegrationsCategorySlugRoute '/api/admin/blog/upload-image': typeof ApiAdminBlogUploadImageRoute @@ -1058,6 +1087,9 @@ export interface FileRoutesByTo { '/api/admin/media/list': typeof ApiAdminMediaListRoute '/api/admin/media/move': typeof ApiAdminMediaMoveRoute '/api/admin/media/upload': typeof ApiAdminMediaUploadRoute + '/api/admin/stars/fetch': typeof ApiAdminStarsFetchRoute + '/api/admin/stars/leads': typeof ApiAdminStarsLeadsRoute + '/api/admin/stars/research': typeof ApiAdminStarsResearchRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -1168,6 +1200,7 @@ export interface FileRoutesById { '/_view/templates/': typeof ViewTemplatesIndexRoute '/admin/collections/': typeof AdminCollectionsIndexRoute '/admin/media/': typeof AdminMediaIndexRoute + '/admin/stars/': typeof AdminStarsIndexRoute '/_view/gallery/$type/$slug': typeof ViewGalleryTypeSlugRoute '/_view/integrations/$category/$slug': typeof ViewIntegrationsCategorySlugRoute '/api/admin/blog/upload-image': typeof ApiAdminBlogUploadImageRoute @@ -1190,6 +1223,9 @@ export interface FileRoutesById { '/api/admin/media/list': typeof ApiAdminMediaListRoute '/api/admin/media/move': typeof ApiAdminMediaMoveRoute '/api/admin/media/upload': typeof ApiAdminMediaUploadRoute + '/api/admin/stars/fetch': typeof ApiAdminStarsFetchRoute + '/api/admin/stars/leads': typeof ApiAdminStarsLeadsRoute + '/api/admin/stars/research': typeof ApiAdminStarsResearchRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -1300,6 +1336,7 @@ export interface FileRouteTypes { | '/templates/' | '/admin/collections/' | '/admin/media/' + | '/admin/stars/' | '/gallery/$type/$slug' | '/integrations/$category/$slug' | '/api/admin/blog/upload-image' @@ -1322,6 +1359,9 @@ export interface FileRouteTypes { | '/api/admin/media/list' | '/api/admin/media/move' | '/api/admin/media/upload' + | '/api/admin/stars/fetch' + | '/api/admin/stars/leads' + | '/api/admin/stars/research' fileRoutesByTo: FileRoutesByTo to: | '/auth' @@ -1426,6 +1466,7 @@ export interface FileRouteTypes { | '/templates' | '/admin/collections' | '/admin/media' + | '/admin/stars' | '/gallery/$type/$slug' | '/integrations/$category/$slug' | '/api/admin/blog/upload-image' @@ -1448,6 +1489,9 @@ export interface FileRouteTypes { | '/api/admin/media/list' | '/api/admin/media/move' | '/api/admin/media/upload' + | '/api/admin/stars/fetch' + | '/api/admin/stars/leads' + | '/api/admin/stars/research' id: | '__root__' | '/_view' @@ -1557,6 +1601,7 @@ export interface FileRouteTypes { | '/_view/templates/' | '/admin/collections/' | '/admin/media/' + | '/admin/stars/' | '/_view/gallery/$type/$slug' | '/_view/integrations/$category/$slug' | '/api/admin/blog/upload-image' @@ -1579,6 +1624,9 @@ export interface FileRouteTypes { | '/api/admin/media/list' | '/api/admin/media/move' | '/api/admin/media/upload' + | '/api/admin/stars/fetch' + | '/api/admin/stars/leads' + | '/api/admin/stars/research' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -1623,6 +1671,9 @@ export interface RootRouteChildren { ApiAdminMediaListRoute: typeof ApiAdminMediaListRoute ApiAdminMediaMoveRoute: typeof ApiAdminMediaMoveRoute ApiAdminMediaUploadRoute: typeof ApiAdminMediaUploadRoute + ApiAdminStarsFetchRoute: typeof ApiAdminStarsFetchRoute + ApiAdminStarsLeadsRoute: typeof ApiAdminStarsLeadsRoute + ApiAdminStarsResearchRoute: typeof ApiAdminStarsResearchRoute } declare module '@tanstack/react-router' { @@ -1865,6 +1916,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ViewAppRouteRouteImport parentRoute: typeof ViewRouteRoute } + '/admin/stars/': { + id: '/admin/stars/' + path: '/stars' + fullPath: '/admin/stars/' + preLoaderRoute: typeof AdminStarsIndexRouteImport + parentRoute: typeof AdminRouteRoute + } '/admin/media/': { id: '/admin/media/' path: '/media' @@ -2376,6 +2434,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ViewAppAccountRouteImport parentRoute: typeof ViewAppRouteRoute } + '/api/admin/stars/research': { + id: '/api/admin/stars/research' + path: '/api/admin/stars/research' + fullPath: '/api/admin/stars/research' + preLoaderRoute: typeof ApiAdminStarsResearchRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/stars/leads': { + id: '/api/admin/stars/leads' + path: '/api/admin/stars/leads' + fullPath: '/api/admin/stars/leads' + preLoaderRoute: typeof ApiAdminStarsLeadsRouteImport + parentRoute: typeof rootRouteImport + } + '/api/admin/stars/fetch': { + id: '/api/admin/stars/fetch' + path: '/api/admin/stars/fetch' + fullPath: '/api/admin/stars/fetch' + preLoaderRoute: typeof ApiAdminStarsFetchRouteImport + parentRoute: typeof rootRouteImport + } '/api/admin/media/upload': { id: '/api/admin/media/upload' path: '/api/admin/media/upload' @@ -2749,12 +2828,14 @@ interface AdminRouteRouteChildren { AdminIndexRoute: typeof AdminIndexRoute AdminCollectionsIndexRoute: typeof AdminCollectionsIndexRoute AdminMediaIndexRoute: typeof AdminMediaIndexRoute + AdminStarsIndexRoute: typeof AdminStarsIndexRoute } const AdminRouteRouteChildren: AdminRouteRouteChildren = { AdminIndexRoute: AdminIndexRoute, AdminCollectionsIndexRoute: AdminCollectionsIndexRoute, AdminMediaIndexRoute: AdminMediaIndexRoute, + AdminStarsIndexRoute: AdminStarsIndexRoute, } const AdminRouteRouteWithChildren = AdminRouteRoute._addFileChildren( @@ -2803,6 +2884,9 @@ const rootRouteChildren: RootRouteChildren = { ApiAdminMediaListRoute: ApiAdminMediaListRoute, ApiAdminMediaMoveRoute: ApiAdminMediaMoveRoute, ApiAdminMediaUploadRoute: ApiAdminMediaUploadRoute, + ApiAdminStarsFetchRoute: ApiAdminStarsFetchRoute, + ApiAdminStarsLeadsRoute: ApiAdminStarsLeadsRoute, + ApiAdminStarsResearchRoute: ApiAdminStarsResearchRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/admin/route.tsx b/apps/web/src/routes/admin/route.tsx index e6c85c3234..7267d292f6 100644 --- a/apps/web/src/routes/admin/route.tsx +++ b/apps/web/src/routes/admin/route.tsx @@ -10,10 +10,10 @@ import { fetchAdminUser } from "@/functions/admin"; export const Route = createFileRoute("/admin")({ head: () => ({ meta: [ - { title: "Content Admin - Hyprnote" }, + { title: "Char Admin - Hyprnote" }, { name: "description", - content: "Manage content and media for Hyprnote.", + content: "Char admin for Hyprnote.", }, { name: "robots", content: "noindex, nofollow" }, ], @@ -72,7 +72,7 @@ function AdminHeader({ user }: { user: { email: string } }) { to="/admin/" className="font-serif2 italic text-stone-600 text-2xl" > - Content Admin + Char Admin diff --git a/apps/web/src/routes/admin/stars/index.tsx b/apps/web/src/routes/admin/stars/index.tsx new file mode 100644 index 0000000000..66ad17ea3a --- /dev/null +++ b/apps/web/src/routes/admin/stars/index.tsx @@ -0,0 +1,458 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { + ChevronDownIcon, + ChevronRightIcon, + DownloadIcon, + ExternalLinkIcon, + RefreshCwIcon, + SearchIcon, + SparklesIcon, + StarIcon, +} from "lucide-react"; +import { useState } from "react"; + +import { Spinner } from "@hypr/ui/components/ui/spinner"; +import { cn } from "@hypr/utils"; + +import type { StarLead } from "@/functions/github-stars"; + +export const Route = createFileRoute("/admin/stars/")({ + component: StarsPage, +}); + +function StarsPage() { + const queryClient = useQueryClient(); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const [filter, setFilter] = useState<"all" | "researched" | "unresearched">( + "all", + ); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ["starLeads", filter], + queryFn: async () => { + const params = new URLSearchParams(); + params.set("limit", "200"); + if (filter === "researched") params.set("researched", "true"); + const response = await fetch(`/api/admin/stars/leads?${params}`); + if (!response.ok) throw new Error("Failed to fetch leads"); + return response.json() as Promise<{ leads: StarLead[]; total: number }>; + }, + }); + + const fetchMutation = useMutation({ + mutationFn: async (source: "stargazers" | "activity") => { + const response = await fetch("/api/admin/stars/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source }), + }); + if (!response.ok) throw new Error("Failed to fetch stars"); + return response.json() as Promise<{ added: number; total: number }>; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["starLeads"] }); + }, + }); + + const researchMutation = useMutation({ + mutationFn: async (username: string) => { + const response = await fetch("/api/admin/stars/research", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + if (!response.ok) { + const err = await response.json(); + throw new Error((err as { error?: string }).error || "Research failed"); + } + return response.json() as Promise<{ + success: boolean; + lead?: StarLead; + }>; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["starLeads"] }); + }, + }); + + const leads = data?.leads ?? []; + const total = data?.total ?? 0; + + const filteredLeads = leads.filter((lead) => { + if (filter === "unresearched" && lead.researched_at) return false; + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return ( + lead.github_username.toLowerCase().includes(q) || + (lead.name && lead.name.toLowerCase().includes(q)) || + (lead.company && lead.company.toLowerCase().includes(q)) + ); + }); + + const toggleRow = (username: string) => { + setExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(username)) { + next.delete(username); + } else { + next.add(username); + } + return next; + }); + }; + + return ( +
+
+
+
+

+ GitHub Stars +

+

+ Track and qualify leads from GitHub activity on fastrepl/hyprnote +

+
+
+ + + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-1.5 text-sm border border-neutral-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-300" + /> +
+ +
+ {(["all", "researched", "unresearched"] as const).map((f) => ( + + ))} +
+ + + {filteredLeads.length} of {total} leads + +
+ + {fetchMutation.isSuccess && ( +
+ Fetched {fetchMutation.data.added} new entries. Total:{" "} + {fetchMutation.data.total} +
+ )} + {fetchMutation.isError && ( +
+ Error: {fetchMutation.error.message} +
+ )} +
+ +
+ {isLoading ? ( +
+ +
+ ) : filteredLeads.length === 0 ? ( +
+ +

No leads found

+

+ Click "Fetch Stars" to pull stargazers from GitHub +

+
+ ) : ( + + + + + + + + + + + + + + {filteredLeads.map((lead) => ( + toggleRow(lead.github_username)} + onResearch={() => + researchMutation.mutate(lead.github_username) + } + isResearching={ + researchMutation.isPending && + researchMutation.variables === lead.github_username + } + /> + ))} + +
+ + User + + Event + + Score + + Match + + Company + + Status + + Actions +
+ )} +
+
+ ); +} + +function ScoreBadge({ score }: { score: number | null }) { + if (score === null || score === undefined) { + return ( + + -- + + ); + } + + let color = "bg-neutral-100 text-neutral-600"; + if (score >= 80) color = "bg-green-100 text-green-700"; + else if (score >= 60) color = "bg-blue-100 text-blue-700"; + else if (score >= 40) color = "bg-yellow-100 text-yellow-700"; + else if (score >= 20) color = "bg-orange-100 text-orange-700"; + else color = "bg-red-100 text-red-600"; + + return ( + + {score} + + ); +} + +function EventBadge({ type }: { type: string }) { + const colors: Record = { + star: "bg-yellow-50 text-yellow-700 border-yellow-200", + fork: "bg-purple-50 text-purple-700 border-purple-200", + issue: "bg-green-50 text-green-700 border-green-200", + pr: "bg-blue-50 text-blue-700 border-blue-200", + comment: "bg-neutral-50 text-neutral-600 border-neutral-200", + push: "bg-indigo-50 text-indigo-700 border-indigo-200", + create: "bg-teal-50 text-teal-700 border-teal-200", + }; + + return ( + + {type} + + ); +} + +function LeadRow({ + lead, + isExpanded, + onToggle, + onResearch, + isResearching, +}: { + lead: StarLead; + isExpanded: boolean; + onToggle: () => void; + onResearch: () => void; + isResearching: boolean; +}) { + return ( + <> + + + {lead.researched_at ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( +
+ )} + + +
+ {lead.avatar_url ? ( + {lead.github_username} + ) : ( +
+ )} +
+ + {lead.name && ( + + @{lead.github_username} + + )} +
+
+ + + + + + + + + {lead.is_match === null ? ( + -- + ) : lead.is_match ? ( + Yes + ) : ( + No + )} + + {lead.company || "--"} + + {lead.researched_at ? ( + + Researched + + ) : ( + Pending + )} + + + + + + {isExpanded && lead.reasoning && ( + + +
+
+ {lead.bio && Bio: {lead.bio}} + {lead.researched_at && ( + + Researched:{" "} + {new Date(lead.researched_at).toLocaleDateString()} + + )} +
+
+
+                  {lead.reasoning}
+                
+
+
+ + + )} + + ); +} diff --git a/apps/web/src/routes/api/admin/stars/fetch.ts b/apps/web/src/routes/api/admin/stars/fetch.ts new file mode 100644 index 0000000000..31548a56e0 --- /dev/null +++ b/apps/web/src/routes/api/admin/stars/fetch.ts @@ -0,0 +1,48 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { + fetchGitHubActivity, + fetchGitHubStargazers, +} from "@/functions/github-stars"; + +export const Route = createFileRoute("/api/admin/stars/fetch")({ + server: { + handlers: { + POST: async ({ request }) => { + const isDev = process.env.NODE_ENV === "development"; + if (!isDev) { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + } + + try { + const body = await request.json().catch(() => ({})); + const source = (body as { source?: string }).source || "stargazers"; + + let result; + if (source === "activity") { + result = await fetchGitHubActivity(); + } else { + result = await fetchGitHubStargazers(); + } + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return new Response( + JSON.stringify({ error: (err as Error).message }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/stars/leads.ts b/apps/web/src/routes/api/admin/stars/leads.ts new file mode 100644 index 0000000000..567f52678f --- /dev/null +++ b/apps/web/src/routes/api/admin/stars/leads.ts @@ -0,0 +1,46 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { listStarLeads } from "@/functions/github-stars"; + +export const Route = createFileRoute("/api/admin/stars/leads")({ + server: { + handlers: { + GET: async ({ request }) => { + const isDev = process.env.NODE_ENV === "development"; + if (!isDev) { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + } + + try { + const url = new URL(request.url); + const limit = parseInt(url.searchParams.get("limit") || "50", 10); + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const researchedOnly = url.searchParams.get("researched") === "true"; + + const { leads, total } = await listStarLeads({ + limit, + offset, + researchedOnly, + }); + + return new Response(JSON.stringify({ leads, total }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return new Response( + JSON.stringify({ error: (err as Error).message }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/stars/research.ts b/apps/web/src/routes/api/admin/stars/research.ts new file mode 100644 index 0000000000..0cd558fcc0 --- /dev/null +++ b/apps/web/src/routes/api/admin/stars/research.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { researchLead } from "@/functions/github-stars"; + +export const Route = createFileRoute("/api/admin/stars/research")({ + server: { + handlers: { + POST: async ({ request }) => { + const isDev = process.env.NODE_ENV === "development"; + if (!isDev) { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + } + + try { + const body = (await request.json()) as { + username: string; + apiKey?: string; + }; + const { username, apiKey } = body; + + if (!username) { + return new Response( + JSON.stringify({ error: "username is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const openrouterKey = apiKey || process.env.OPENROUTER_API_KEY || ""; + if (!openrouterKey) { + return new Response( + JSON.stringify({ + error: + "OpenRouter API key is required. Set OPENROUTER_API_KEY env var or pass apiKey in body.", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await researchLead(username, openrouterKey); + + return new Response(JSON.stringify(result), { + status: result.success ? 200 : 400, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return new Response( + JSON.stringify({ error: (err as Error).message }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + }, + }, + }, +}); diff --git a/supabase/migrations/20250206000000_create_github_star_leads.sql b/supabase/migrations/20250206000000_create_github_star_leads.sql new file mode 100644 index 0000000000..f81ee2e4de --- /dev/null +++ b/supabase/migrations/20250206000000_create_github_star_leads.sql @@ -0,0 +1,21 @@ +create table if not exists public.github_star_leads ( + id bigint generated always as identity primary key, + github_username text not null unique, + github_id bigint, + avatar_url text, + profile_url text, + bio text, + event_type text not null default 'star', + repo_name text not null default 'fastrepl/hyprnote', + name text, + company text, + is_match boolean, + score integer, + reasoning text, + researched_at timestamptz, + event_at timestamptz not null default now(), + created_at timestamptz not null default now() +); + +create index if not exists idx_github_star_leads_score on public.github_star_leads (score desc nulls last); +create index if not exists idx_github_star_leads_event_type on public.github_star_leads (event_type); diff --git a/supabase/migrations/20250206000001_github_star_leads_rls.sql b/supabase/migrations/20250206000001_github_star_leads_rls.sql new file mode 100644 index 0000000000..26b985cfc8 --- /dev/null +++ b/supabase/migrations/20250206000001_github_star_leads_rls.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.github_star_leads ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "github_star_leads_service_all" ON public.github_star_leads AS PERMISSIVE FOR ALL TO "service_role" USING (true) WITH CHECK (true);