spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1"use client";23import { useState } from "react";4import { Search } from "lucide-react";5import { useApi } from "@/lib/api";6import { fmtNum } from "@/lib/format";7import { Empty, Panel, Skeleton } from "@/components/ui";8import { EntityTable, type EntityRow } from "@/components/EntityTable";910const TYPES = ["", "video", "channel", "profile", "post", "comment", "community", "hashtag", "page"];11const PLATFORMS = ["", "youtube", "reddit", "facebook", "instagram", "tiktok", "x"];1213export default function Entities() {14 const [q, setQ] = useState("");15 const [type, setType] = useState("");16 const [platform, setPlatform] = useState("");17 const [sort, setSort] = useState("last_seen");18 const [page, setPage] = useState(0);19 const limit = 50;20 const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit), sort });21 if (q) params.set("q", q);22 if (type) params.set("type", type);23 if (platform) params.set("platform", platform);24 const { data, loading } = useApi<{ rows: EntityRow[]; total: number }>(`/entities?${params}`);25 const sel = "rounded-lg bg-bg-2 border border-line-2 px-2 py-1.5 text-sm text-fg outline-none focus:border-cyan/60";26 return (27 <div className="space-y-4">28 <div className="flex flex-wrap items-end gap-3">29 <div>30 <h1 className="text-2xl font-semibold tracking-tight">Entities</h1>31 <p className="text-sm text-fg-2">Canonical entities with per-field evidence. {data ? <span className="mono">{fmtNum(data.total)} match</span> : null}</p>32 </div>33 <div className="ml-auto flex flex-wrap gap-2 items-center">34 <label className="relative">35 <Search className="h-4 w-4 absolute left-2 top-2 text-dim" />36 <input37 value={q}38 onChange={(e) => {39 setQ(e.target.value);40 setPage(0);41 }}42 placeholder="search name, text, author, id…"43 className={`${sel} pl-8 w-72 mono`}44 />45 </label>46 <select value={type} onChange={(e) => (setType(e.target.value), setPage(0))} className={sel}>47 {TYPES.map((t) => (48 <option key={t} value={t}>49 {t || "all types"}50 </option>51 ))}52 </select>53 <select value={platform} onChange={(e) => (setPlatform(e.target.value), setPage(0))} className={sel}>54 {PLATFORMS.map((p) => (55 <option key={p} value={p}>56 {p || "all platforms"}57 </option>58 ))}59 </select>60 <select value={sort} onChange={(e) => setSort(e.target.value)} className={sel}>61 <option value="last_seen">recently seen</option>62 <option value="seen">most observed</option>63 <option value="views">most views</option>64 </select>65 </div>66 </div>67 <Panel pad={false}>68 <div className="p-4">{loading ? <Skeleton rows={10} /> : data?.rows.length ? <EntityTable rows={data.rows} showPlatform /> : <Empty>Nothing matches.</Empty>}</div>69 {data && data.total > limit && (70 <div className="flex items-center gap-3 px-4 py-3 border-t border-line text-xs">71 <button disabled={page === 0} onClick={() => setPage((p) => p - 1)} className="px-2 py-1 rounded border border-line disabled:opacity-40 hover:border-cyan/50">72 ← prev73 </button>74 <span className="mono text-dim">75 {page * limit + 1}–{Math.min(data.total, (page + 1) * limit)} of {data.total}76 </span>77 <button disabled={(page + 1) * limit >= data.total} onClick={() => setPage((p) => p + 1)} className="px-2 py-1 rounded border border-line disabled:opacity-40 hover:border-cyan/50">78 next →79 </button>80 </div>81 )}82 </Panel>83 </div>84 );85}86