TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import { Bookmark as BookmarkIcon, Plus, Trash2 } from "lucide-react";4import Link from "next/link";5import { useCallback, useEffect, useState } from "react";6import { EventRow } from "@/components/event-row";7import { Chip, Empty, Panel, SkeletonRows } from "@/components/ui";8import type { Bookmark } from "@/lib/api";9import { relTime, SAVED_VIEWS, utcDateTime } from "@/lib/format";10import { ownerFetch } from "@/lib/owner";1112interface SavedView {13 id: string;14 name: string;15 query: string;16 created_at?: string;17}1819function normalizeQuery(q: string): string {20 const s = q.trim().replace(/^\/?live/, "").replace(/^\?/, "");21 return s;22}2324export function Bookmarks() {25 const [items, setItems] = useState<Bookmark[] | null>(null);26 const [views, setViews] = useState<SavedView[] | null>(null);27 const [err, setErr] = useState<string | null>(null);28 const [viewErr, setViewErr] = useState<string | null>(null);29 const [form, setForm] = useState({ name: "", query: "" });3031 const load = useCallback(32 () =>33 ownerFetch<{ items: Bookmark[] }>("/api/v1/bookmarks?limit=200")34 .then((r) => setItems(r.items))35 .catch((e: Error) => {36 setErr(e.message);37 setItems([]);38 }),39 [],40 );41 const loadViews = useCallback(42 () =>43 ownerFetch<{ items: SavedView[] }>("/api/v1/views")44 .then((r) => setViews(r.items))45 .catch((e: Error) => {46 setViewErr(e.message);47 setViews([]);48 }),49 [],50 );51 useEffect(() => {52 void load();53 void loadViews();54 }, [load, loadViews]);5556 const remove = async (id: string): Promise<void> => {57 setItems((prev) => prev?.filter((b) => b.id !== id) ?? prev);58 try {59 await ownerFetch(`/api/v1/bookmarks/${encodeURIComponent(id)}`, { method: "DELETE" });60 } catch (e) {61 setErr((e as Error).message);62 await load();63 }64 };6566 const createView = async (): Promise<void> => {67 const query = normalizeQuery(form.query);68 if (!query) {69 setViewErr("Paste a live-feed query string, e.g. group=security&signal_min=70");70 return;71 }72 setViewErr(null);73 try {74 await ownerFetch("/api/v1/views", { method: "POST", body: JSON.stringify({ name: form.name.trim() || "My view", query }) });75 setForm({ name: "", query: "" });76 await loadViews();77 } catch (e) {78 setViewErr((e as Error).message);79 }80 };81 const removeView = async (id: string): Promise<void> => {82 setViews((prev) => prev?.filter((v) => v.id !== id) ?? prev);83 try {84 await ownerFetch(`/api/v1/views/${encodeURIComponent(id)}`, { method: "DELETE" });85 } catch (e) {86 setViewErr((e as Error).message);87 await loadViews();88 }89 };9091 return (92 <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]">93 <Panel title={<span>Saved events <span className="font-mono text-fg-subtle">{items?.length ?? "…"}</span></span>} dense>94 {items === null ? (95 <SkeletonRows rows={6} />96 ) : items.length ? (97 <ul className="divide-y divide-line">98 {items.map((b) => (99 <li key={b.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 pr-2">100 <div className="min-w-0 [&>article]:border-b-0">101 <EventRow ev={b} showDate />102 </div>103 <div className="flex flex-col items-end gap-1 pt-2">104 <button type="button" aria-label="Remove bookmark" title="Remove bookmark" onClick={() => remove(b.id)} className="inline-flex size-7 items-center justify-center rounded-md border border-line text-fg-subtle hover:border-line-strong hover:text-danger">105 <Trash2 className="size-3.5" />106 </button>107 <span className="inline-flex items-center gap-1 whitespace-nowrap font-mono text-[10.5px] text-fg-subtle" title={`Saved ${utcDateTime(b.bookmarked_at)}${b.note ? ` · ${b.note}` : ""}`}>108 <BookmarkIcon className="size-3 text-signal" /> {relTime(b.bookmarked_at)}109 </span>110 </div>111 </li>112 ))}113 </ul>114 ) : (115 <Empty icon={<BookmarkIcon className="size-5 text-fg-subtle" />}>116 No saved events yet — use the bookmark icon in the intelligence panel.117 <div className="mt-2">118 <Link href="/live" className="text-info hover:underline">Open the live feed →</Link>119 </div>120 </Empty>121 )}122 {err && <p className="border-t border-line px-3 py-2 text-[11px] text-danger">{err}</p>}123 </Panel>124125 <aside className="flex min-w-0 flex-col gap-4">126 <Panel title={<span>Saved views <span className="font-mono text-fg-subtle">{views?.length ?? "…"}</span></span>} dense>127 {views === null ? (128 <SkeletonRows rows={3} />129 ) : views.length ? (130 <ul className="divide-y divide-line">131 {views.map((v) => (132 <li key={v.id} className="flex items-start gap-2 px-3 py-2 text-[13px]">133 <div className="min-w-0 flex-1">134 <Link href={`/live?${v.query}`} className="block truncate font-medium hover:underline">{v.name}</Link>135 <div className="mt-0.5 truncate font-mono text-[11px] text-fg-subtle" title={v.query}>?{v.query}</div>136 </div>137 <button type="button" aria-label={`Delete view ${v.name}`} onClick={() => removeView(v.id)} className="mt-0.5 text-fg-subtle hover:text-danger">138 <Trash2 className="size-3.5" />139 </button>140 </li>141 ))}142 </ul>143 ) : (144 <Empty>No saved views yet. Filter the live feed, then paste its query string below.</Empty>145 )}146 <form147 className="flex flex-col gap-1.5 border-t border-line p-2 text-[12.5px]"148 onSubmit={(e) => {149 e.preventDefault();150 void createView();151 }}152 >153 <label className="sr-only" htmlFor="view-name">View name</label>154 <input id="view-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="View name" maxLength={60} className="h-8 rounded-md border border-line bg-panel px-2 text-[12.5px]" />155 <label className="sr-only" htmlFor="view-query">Query string</label>156 <input id="view-query" value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} placeholder="group=security&signal_min=70" maxLength={600} className="h-8 rounded-md border border-line bg-panel px-2 font-mono text-[12px]" />157 <div className="flex items-center justify-between gap-2">158 <button type="button" onClick={() => setForm({ ...form, query: typeof window !== "undefined" && window.location.pathname.startsWith("/live") ? normalizeQuery(window.location.search) : form.query })} className="text-[11px] text-fg-subtle hover:text-fg" title="Only works from a /live page">use current query</button>159 <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong">160 <Plus className="size-3.5" /> Save view161 </button>162 </div>163 {viewErr && <p className="text-[11px] text-danger">{viewErr}</p>}164 </form>165 </Panel>166 <Panel title="Built-in views" dense>167 <ul className="divide-y divide-line">168 {SAVED_VIEWS.map((v) => {169 const q = new URLSearchParams(Object.entries(v.query).map(([k, val]) => [k, String(val)])).toString();170 return (171 <li key={v.key} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]">172 <Link href={`/live?${q}`} className="min-w-0 truncate hover:underline">{v.label}</Link>173 <button type="button" onClick={() => setForm({ name: v.label, query: q })} className="shrink-0" title="Copy into the form">174 <Chip className="hover:border-line-strong">copy</Chip>175 </button>176 </li>177 );178 })}179 </ul>180 </Panel>181 </aside>182 </div>183 );184}185